具体例で理解するuseMemoとuseCallbackの使い方。Reactパフォーマンスチューニング

JavaScript

コンポーネントや関数のメモ化を行うことで、不要な計算やレンダリングを抑えられるためパフォーマンス向上が期待できます。

useMemoやuseCallbackはメモ化の機能を提供するフックです。
関数コンポーネントにおいて、メモ化を活用したパフォーマンス改善をする場合はuseMemoやuseCallbackを利用します。

今回はuseMemo・useCallbackの利用方法について具体例をもとに紹介します。

reactは17.0.1を利用しています。

今回利用するサンプルアプリケーションについて

パフォーマンスが改善されていることを確認するため、ボタンをクリックした回数が表示されるアプリケーションをサンプルとして用意しました。2つボタンを配置し、片方だけクリックの処理を重くしています。

パフォーマンスチューニング前のサンプルアプリケーションでは、クリックの処理のコストがコンポーネント全体に影響を及ぼしてしまい、画面全体が遅くなっています。つまり、どちらのボタンを押しても反応が遅い状態です。

今回は画面全体の処理の遅さをuseMemoやuseCallbackを利用することで改善します。

useMemoについて

useMemoは関数の結果をメモ化するフックです。
処理の重いメソッドがある場合、useMemoを活用することでパフォーマンスの改善が期待できます。

useMemoはuseMemo(() => 結果を算出するロジック, 依存配列)という形で利用します。
依存配列の要素のうちいずれかが変化した場合のみ再計算されます。
依存配列が設定されていない場合はレンダリングのたびに計算が実行されます。

useMemoの使用例

useMemoの具体例について紹介します。

関数の結果のメモ化

以下のサンプルコードに記載されているheavyFunctionは処理に時間のかかるメソッドです。
コンポーネントをレンダリングする際にheavyFunction(countHeavy)の計算を毎回行っているため、コンポーネント全体の動作が重くなっています。
そのため、Heavyボタンだけでなく、Normalボタンのクリックの処理も重い状態です。

App.tsx

import React, { useState } from "react";
import "./App.css";

const App: React.FC = () => {
  const [countNormal, setCountNormal] = useState(0);
  const [countHeavy, setCountHeavy] = useState(0);

  const heavyFunction = (count: number) => {
    let i = 0;
    while (i < 1000000000) i++;
    return count;
  };

  return (
    <div className="app">
      <div className="app-counter">
        <div>Normal: {countNormal}</div>
        <div>Heavy: {heavyFunction(countHeavy)}</div>
      </div>
      <div className="app-button">
        <button onClick={() => setCountNormal(countNormal + 1)}>Normal+</button>
        <button onClick={() => setCountHeavy(countHeavy + 1)}>Heavy+</button>
      </div>
    </div>
  );
};

export default App;

useMemoを利用してパフォーマンス改善をした例が以下になります。
heavyFunction(countHeavy)countHeavyが変更した場合のみ再計算するようにしたため、Normalボタンのクリックの挙動が改善されました。

App.tsx

import React, { useState, useMemo } from "react";
import "./App.css";

const App: React.FC = () => {
  const [countNormal, setCountNormal] = useState(0);
  const [countHeavy, setCountHeavy] = useState(0);

  const heavyFunction = (count: number) => {
    let i = 0;
    while (i < 1000000000) i++;
    return count;
  };

  // 関数の結果をメモ化する
+ const heavyFunctionMemo = useMemo(() => heavyFunction(countHeavy), [
+   countHeavy,
+ ]);

  // Before: Normalボタン、Heavyボタンの挙動が重い
  // After: Heavyボタンの挙動のみ重い
  return (
    <div className="app">
      <div className="app-counter">
        <div>Normal: {countNormal}</div>
-       <div>Heavy: {heavyFunction(countHeavy)}</div>
+       <div>Heavy: {heavyFunctionMemo}</div>
      </div>
      <div className="app-button">
        <button onClick={() => setCountNormal(countNormal + 1)}>Normal+</button>
        <button onClick={() => setCountHeavy(countHeavy + 1)}>Heavy+</button>
      </div>
    </div>
  );
};

export default App;

useMemoに直接ロジックを書いた場合、以下のように書き換えられます。

const heavyFunctionMemo = useMemo(() => {
  let i = 0;
  while (i < 1000000000) i++;
  return countHeavy;
}, [countHeavy]);

// 以下の実装と同じ
//
// const heavyFunction = (count: number) => {
//   let i = 0;
//   while (i < 1000000000) i++;
//   return count;
// };
// const heavyFunctionMemo = useMemo(() => heavyFunction(countHeavy), [
//   countHeavy,
// ]);

コンポーネントのメモ化

useMemoはReact.memo(後述)のようにコンポーネントのメモ化もできます。

以下のサンプルコードではHeavy: {count}の表示部分を子コンポーネントに切り出しています。
子コンポーネントはレンダリングに時間がかかります(レンダリング時に実行されるuseEffectの時間がかかるため)。
App.tsxはレンダリングのたびに子コンポーネントを再作成するため、コンポーネント全体の動作が重くなっています。
そのため、Heavyボタンだけでなく、Normalボタンのクリックの処理も重い状態です。

App.tsx

import React, { useState } from "react";
import "./App.css";
import Child from "./Child";

const App: React.FC = () => {
  const [countNormal, setCountNormal] = useState(0);
  const [countHeavy, setCountHeavy] = useState(0);

  return (
    <div className="app">
      <div className="app-counter">
        <div>Normal: {countNormal}</div>
        <Child count={countHeavy} />
      </div>
      <div className="app-button">
        <button onClick={() => setCountNormal(countNormal + 1)}>Normal+</button>
        <button onClick={() => setCountHeavy(countHeavy + 1)}>Heavy+</button>
      </div>
    </div>
  );
};

export default App;

Child.tsx

import { useEffect } from "react";

type ChildProps = {
  count: number;
};

const Child: React.FC<ChildProps> = ({ count }) => {
  useEffect(() => {
    let i: number = 0;
    while (i < 1000000000) i++;
  });
  return <div>Heavy: {count}</div>;
};
export default Child;

useMemoを利用してパフォーマンス改善をした例が以下になります。
countHeavyが変更した場合のみ子コンポーネントをレンダリングするようにしたため、Normalボタンのクリックの挙動が改善されました。

App.tsx

import React, { useState, useMemo } from "react";
import "./App.css";
import Child from "./Child";

const App: React.FC = () => {
  const [countNormal, setCountNormal] = useState(0);
  const [countHeavy, setCountHeavy] = useState(0);

  // コンポーネントをメモ化する
+ const ChildMemo = useMemo(() => <Child count={countHeavy} />, [countHeavy]);

  // Before: Normalボタン、Heavyボタンの挙動が重い
  // After: Heavyボタンの挙動のみ重い
  return (
    <div className="app">
      <div className="app-counter">
        <div>Normal: {countNormal}</div>
-       <Child count={countHeavy} />
+       {ChildMemo}
      </div>
      <div className="app-button">
        <button onClick={() => setCountNormal(countNormal + 1)}>Normal+</button>
        <button onClick={() => setCountHeavy(countHeavy + 1)}>Heavy+</button>
      </div>
    </div>
  );
};

export default App;

useCallbackについて

useMemoは『関数の結果』をメモ化するフックであったのに対し、useCallbackは『関数』をメモ化するフックです。つまり、useCallback(fn, deps)useMemo(() => fn, deps)と等価です。1

関数コンポーネントにおいて、レンダリングが発生するとコンポーネント内で定義されたアロー関数は再作成されます。
useCallbackを利用することでレンダリング時の関数の再作成をスキップでき、パフォーマンスの改善が期待できます。

useCallbackはuseCallback(関数, 依存配列)という形で利用します。
依存配列の要素のうちいずれかが変化した場合にのみ再計算されます。

useCallbackの使用例

useCallbackの具体例について紹介します。

コールバック関数をPropsに持つコンポーネントのメモ化

先ほど紹介したように、コンポーネントのメモ化はuseMemoによって実現できます。
依存配列の値が変更されない限りuseMemoはコンポーネントを再利用します。

しかし、メモ化したコンポーネントにコールバック関数を渡している場合は注意が必要です。

なぜなら、関数コンポーネント内で定義したアロー関数はレンダリングのたびに再作成されるからです。
アロー関数がレンダリングによって再作成された結果、依存配列のコールバック関数が変化したとみなされ、メモ化したコンポーネントは再利用されません。

以下のサンプルコードではHeavyボタンを子コンポーネントに切り出しています。
子コンポーネントはレンダリングに時間がかかります(レンダリング時に実行されるuseEffectの時間がかかるため)。
useMemoを利用して子コンポーネントをメモ化しようとしていますが、handleClickはレンダリングのたびに再作成されるためメモ化がうまくいかず、コンポーネント全体の動作が重くなっています。
そのため、Heavyボタンだけでなく、Normalボタンのクリックの処理も重い状態です。

App.tsx

import React, { useState, useMemo } from "react";
import "./App.css";
import Child from "./Child";

const App: React.FC = () => {
  const [countNormal, setCountNormal] = useState(0);
  const [countHeavy, setCountHeavy] = useState(0);

  const handleClick = () => {
    setCountHeavy(countHeavy + 1);
  };

  // handleClickが毎回作成されるため、コンポーネントのメモ化がうまくいかない
  const ChildMemo = useMemo(() => <Child onClick={handleClick} />, [
    handleClick,
  ]);

  return (
    <div className="app">
      <div className="app-counter">
        <div>Normal: {countNormal}</div>
        <div>Heavy: {countHeavy}</div>
      </div>
      <div className="app-button">
        <button onClick={() => setCountNormal(countNormal + 1)}>Normal+</button>
        {ChildMemo}
      </div>
    </div>
  );
};

export default App;

Child.tsx

import { useEffect } from "react";

type ChildProps = {
  onClick: () => void;
};

const Child: React.FC<ChildProps> = ({ onClick }) => {
  useEffect(() => {
    let i: number = 0;
    while (i < 1000000000) i++;
  });
  return <button onClick={onClick}>Heavy+</button>;
};
export default Child;

useCallbackを利用してパフォーマンス改善をした例が以下になります。
handleClickをメモ化したため、Normalボタンのクリックの挙動が改善されました。

App.tsx

import React, { useState, useMemo, useCallback } from "react";
import "./App.css";
import Child from "./Child";

const App: React.FC = () => {
  const [countNormal, setCountNormal] = useState(0);
  const [countHeavy, setCountHeavy] = useState(0);

-  const handleClick = () => {
-    setCountHeavy(countHeavy + 1);
-  };

   // countHeavyが変更されない限り、関数はメモ化される
+  const handleClick = useCallback(() => {
+    setCountHeavy(countHeavy + 1);
+  }, [countHeavy]);

  // コンポーネントのメモ化がうまくいく
  const ChildMemo = useMemo(() => <Child onClick={handleClick} />, [
    handleClick,
  ]);

  // Before: Normalボタン、Heavyボタンの挙動が重い
  // After: Heavyボタンの挙動のみ重い
  return (
    <div className="app">
      <div className="app-counter">
        <div>Normal: {countNormal}</div>
        <div>Heavy: {countHeavy}</div>
      </div>
      <div className="app-button">
        <button onClick={() => setCountNormal(countNormal + 1)}>Normal+</button>
        {ChildMemo}
      </div>
    </div>
  );
};

export default App;

副作用で利用される関数のメモ化

関数コンポーネントにおいて、副作用はuseEffectを利用して実行します。

useEffectは第2引数に依存配列を設定できます。
第2引数を設定することで、依存配列が変更した時のみuseEffectが実行されます。

以下のサンプルコードではボタンのクリックに応じてログを表示しています。heavyFunctionは処理に時間のかかるメソッドです。

本来はHeavyボタンが押された時のみheavyFunctionが実行される想定でした。
しかし、レンダリングのたびにheavyFunctionが再作成されるため、heavyFunctionに関するuseEffectは毎回実行されます。
その結果、コンポーネント全体の動作が重くなっています。

そのため、Heavyボタンだけでなく、Normalボタンのクリックの処理も重い状態です。

App.tsx

import React, { useState, useEffect } from "react";
import "./App.css";

const App: React.FC = () => {
  const [countNormal, setCountNormal] = useState(0);
  const [countHeavy, setCountHeavy] = useState(0);

  const heavyFunction = (type: string, count: number) => {
    let i: number = 0;
    while (i < 1000000000) i++;
    console.log(`count${type} is clicked  ${count} times!`);
  };

  // countNormalボタンがクリックされたら実行される
  useEffect(() => {
    console.log(`countNormal is clicked!`);
  }, [countNormal]);

  // countHeavyボタンだけでなく、countNormalボタンがクリックされても実行される
  // つまり、countNormalボタンをクリックしたときも実行が遅い
  useEffect(() => {
    heavyFunction("Heavy", countHeavy);
  }, [heavyFunction]);

  return (
    <div className="app">
      <div className="app-counter">
        <div>Normal: {countNormal}</div>
        <div>Heavy: {countHeavy}</div>
      </div>
      <div className="app-button">
        <button onClick={() => setCountNormal(countNormal + 1)}>Normal+</button>
        <button onClick={() => setCountHeavy(countHeavy + 1)}>Heavy+</button>
      </div>
    </div>
  );
};

export default App;

useCallbackを利用してパフォーマンス改善をした例が以下になります。
heavyFunctionをメモ化したことで不要なuseEffectの実行が抑えられ、Normalボタンのクリックの挙動が改善されました。

App.tsx

import React, { useState, useEffect, useCallback } from "react";

import "./App.css";

const App: React.FC = () => {
  const [countNormal, setCountNormal] = useState(0);
  const [countHeavy, setCountHeavy] = useState(0);

- const heavyFunction = (type: string, count: number) => {
-   let i: number = 0;
-   while (i < 1000000000) i++;
-   console.log(`count${type} is clicked  ${count} times!`);
- };

  // countHeavyが変更されない限り、関数はメモ化される
+ const heavyFunction = useCallback(
+   (type: string, count: number) => {
+     let i: number = 0;
+     while (i < 1000000000) i++;
+     console.log(`count${type} is clicked  ${count} times!`);
+   },
+   [countHeavy]
+ );

  // countNormalボタンがクリックされたら実行される
  useEffect(() => {
    console.log(`countNormal is clicked!`);
  }, [countNormal]);

  // countHeavyボタンがクリックされたら実行される
  useEffect(() => {
    heavyFunction("Heavy", countHeavy);
  }, [heavyFunction]);

  // Before: Normalボタン、Heavyボタンの挙動が重い
  // After: Heavyボタンの挙動のみ重い
  return (
    <div className="app">
      <div className="app-counter">
        <div>Normal: {countNormal}</div>
        <div>Heavy: {countHeavy}</div>
      </div>
      <div className="app-button">
        <button onClick={() => setCountNormal(countNormal + 1)}>Normal+</button>
        <button onClick={() => setCountHeavy(countHeavy + 1)}>Heavy+</button>
      </div>
    </div>
  );
};

export default App;

なお、useEffectの第2引数の利用方法についてはパフォーマンス改善やバグ防止に理解必須。useEffect第2引数の利用パターン集で紹介しています。

パフォーマンス改善やバグ防止に理解必須。useEffect第2引数の利用パターン集

参考: コンポーネントのメモ化をuseMemoからReact.memoに書き換える

React.memoはクラスコンポーネントにおけるshouldComponentUpdate()React.PureComponentに相当する、コンポーネントをメモ化する機能です。

React.memoではPropsの浅い比較が行われ、結果がfalseの場合のみコンポーネントがレンダリングされます。2

React.memoは高階コンポーネント(Higher-Order Component, HOC)の一種ですので、React.memo(コンポーネント)という形でコンポーネントを囲むことで利用できます。

React.memoによってメモ化されたコンポーネント内でuseStateやuseContextを利用する場合、Stateやコンテキストの変化に応じたレンダリングが発生するので注意が必要です。2

コンポーネントのメモ化をuseMemoからReact.memoに書き換えると以下のようになります。

App.tsx

import React, { useState, useMemo } from "react";
import "./App.css";
import Child from "./Child";

const App: React.FC = () => {
  const [countNormal, setCountNormal] = useState(0);
  const [countHeavy, setCountHeavy] = useState(0);

- const ChildMemo = useMemo(() => <Child count={countHeavy} />, [countHeavy]);

  return (
    <div className="app">
      <div className="app-counter">
        <div>Normal: {countNormal}</div>
-       {ChildMemo}
+       <Child count={countHeavy} />
      </div>
      <div className="app-button">
        <button onClick={() => setCountNormal(countNormal + 1)}>Normal+</button>
        <button onClick={() => setCountHeavy(countHeavy + 1)}>Heavy+</button>
      </div>
    </div>
  );
};

export default App;

Child.tsx

import React, { useEffect } from "react";

type ChildProps = {
  count: number;
};

- const Child: React.FC<ChildProps> = ({ count }) => {
+ const Child: React.FC<ChildProps> = React.memo(({ count }) => {
  useEffect(() => {
    let i: number = 0;
    while (i < 1000000000) i++;
  });
  return <div>Heavy: {count}</div>;
- };
+ });

export default Child;

もしくは以下のようにexportしたコンポーネントをReact.memoで囲む形でもOKです。

Child.tsx

import React, { useEffect } from "react";

type ChildProps = {
  count: number;
};

const Child: React.FC<ChildProps> = ({ count }) => {
  useEffect(() => {
    let i: number = 0;
    while (i < 1000000000) i++;
  });
  return <div>Heavy: {count}</div>;
};

- export default Child;
+ export default React.memo(Child);

まとめ

以上でuseMemo、useCallbackの利用方法について紹介を終わります。

今回のまとめ
  • useMemoは『関数の結果』と『コンポーネント』のメモ化をするフック
  • useCallbackは『関数』のメモ化をするフック
  • コールバック関数を渡すコンポーネントのメモ化はuseMemoとuseCallbackを組み合わせる
  • コンポーネントのメモ化はuseMemoもしくはReact.memoで実装可能
  • コンポーネント内に定義されたアロー関数はレンダリングで再作成されるので注意

Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!