🧠

【React】memo, useMemo, useCallbackを使いこなす

に公開

はじめに

Reactアプリケーションのパフォーマンスチューニングにおいて、「メモ化(Memoization)」は頻繁に議論されるテクニックです。
React.memouseMemouseCallback は、適切に使えば不要な再レンダリングや計算を削減するHowToですが、誤った使い方や過剰な適用は、逆にコードの複雑性を増し、期待した効果が得られないことも少なくありません。
将来的には、React Compilerがこれらのメモ化APIの多くを自動化し、開発者の負担を軽減することが期待されていますが、現時点では手動での最適化が重要と捉えています。

筆者自身、直近DRESS CODEの開発において、企業活動における「手続き」や「申請」に利用する汎用的なフォーム生成モジュールを実装する機会がありました。
その際、まさにパフォーマンスチューニングが必要となり、メモ化APIと向き合うことになりました。
この経験を通じて、メモ化の難しさ、特に意図しない再レンダリングを引き起こす「落とし穴」を避けることの重要性を改めて痛感しました。
本記事では、そうした実践的な経験も踏まえつつ、これらのメモ化APIの正確な理解、よくある落とし穴、そしてパフォーマンス最適化における適切な位置づけについて、具体的な注意点と共に深掘りします。

なぜメモ化が必要なのか?

メモ化の必要性を理解する鍵は、JavaScriptにおける値の比較方法、特に 等価性の比較 にあります。プリミティブ値(数値、文字列、真偽値など)は「値」で比較されますが、オブジェクト、配列、関数は「参照」で比較されます。つまり、メモリ上の同じ場所を指しているかどうかで等価性が判断されます。

// プリミティブな値で比較
console.log(1 === 1); // true
console.log('hello' === 'hello'); // true

// オブジェクト、配列、関数は参照で比較 (=== 演算子)
console.log({} === {}); // false (異なるオブジェクト参照)
console.log([] === []); // false (異なる配列参照)
console.log((() => {}) === (() => {})); // false (異なる関数参照)

const objA = { id: 1 };
const objB = objA; // 同じ参照を代入
console.log(objA === objB); // true

https://developer.mozilla.org/ja/docs/Web/JavaScript/Equality_comparisons_and_sameness

Reactコンポーネントが再レンダリングされる際、コンポーネント内で定義されたオブジェクト、配列、関数は通常、新しい参照を持つ新しいインスタンスとして再生成されます。
これが子コンポーネントのpropsやフック (Hooks) の依存配列に渡されると、たとえ内容が実質的に同じでも、Reactは参照が異なるため「変更された」と判断します。
これにより、不要な再レンダリングやuseEffectなどの副作用の再実行がトリガーされる可能性があります。
メモ化は、この参照の同一性を意図的に維持することで、不要な処理を防ぐためのテクニックです。

パフォーマンス最適化の第一歩:測定と分析

最適化を始める前に、何が問題なのかを正確に把握することが不可欠です。憶測で最適化を行うのではなく、ツールを使ってボトルネックを特定しましょう。

React DevTools Profilerを活用する
ブラウザの拡張機能として提供されているReact DevToolsにはProfilerタブがあります。これを使ってアプリケーションのインタラクションを記録することで、以下の情報を得られます。

  • コミット時間
    • どのコンポーネントツリーの更新に時間がかかっているか
  • レンダリング回数
    • 各コンポーネントが何回レンダリングされたか。特に、propsが変わっていないのに再レンダリングされているコンポーネントは最適化の候補です
  • Flamegraphチャート
    • 各コンポーネントのレンダリング時間を視覚的に表示し、最もコストの高いコンポーネントを特定するのに役立ちます

https://ja.react.dev/learn/react-developer-tools

ボトルネックの特定

  • Profilerの結果を見て、「なぜこのコンポーネントは再レンダリングされたのか?」を分析します
  • 親コンポーネントの再レンダリングが原因か、propsの参照が変わったのか、Contextの更新が原因かなどを突き止めます

測定と分析によって具体的な問題箇所を特定することが、効果的な最適化への第一歩です。

メモ化の前に:設計によるパフォーマンス改善

多くの場合、パフォーマンスの問題はメモ化APIを使わなくても、コンポーネントの設計や状態 (State)管理を見直すことで解決できます。

  • コンポジション(Composition)を活かす

    • 状態を持つコンポーネントと、その状態に依存する表示コンポーネントを適切に分離しましょう。 一つのコンポーネントが持つ責務を小さく保つ ことで、状態変化の影響範囲を最小限に抑え、不要な再レンダリングは自然と減少します。
    // 改善前: ExpensiveComponentがcountの変更で再レンダリングされる
    const ParentWithState = () => {
      const [count, setCount] = useState(0);
      return (
        <div>
          <button onClick={() => setCount(c => c + 1)}>Increment {count}</button>
          <ExpensiveComponent />
        </div>
      );
    };
    
    // 改善後: 状態(count)を持つCounterとExpensiveComponentを分離
    const Counter = () => {
      const [count, setCount] = useState(0);
      return <button onClick={() => setCount(c => c + 1)}>Increment {count}</button>;
    };
    const App = () => {
      return (
        <div>
          <Counter /> {/* Counterのみが再レンダリングされる */}
          <ExpensiveComponent />
        </div>
      );
    };
    
  • 状態のリフティング(State Lifting)は適切か?:

    • 状態を不必要に多くのコンポーネントが共有する上位のコンポーネントに配置していませんか? 状態は、それを本当に必要とする最も近い共通の祖先に配置し、存在可能なスコープをできるだけ小さく保つState Colocation とも呼ばれる考え方)のが基本です。詳細は 「コンポーネント間で state を共有する」 を参考にしています。

https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster

https://ja.react.dev/learn/managing-state#sharing-state-between-components

  • Context APIの利用範囲
    • Contextは便利ですが、更新が頻繁な値をContextで管理すると、そのContextを参照する全てのコンポーネントが再レンダリングされる可能性があります。
    • これはパフォーマンス上の注意点となります。グローバルな状態管理や更新頻度の高い状態には、Zustand, Jotaiなどのライブラリや、より細かい粒度でのContext分割を検討しましょう。

https://ja.react.dev/learn/passing-data-deeply-with-context

https://ja.react.dev/reference/react/useContext#optimizing-re-renders-when-passing-objects-and-functions

設計の見直しだけでパフォーマンスが改善することも多々あります。まずはこれらの基本的な設計原則を確認することをお勧めします。

Reactメモ化API:それぞれの役割とコスト

設計を見直してもなおパフォーマンスのボトルネックが残る場合、メモ化APIの出番となります。各APIの目的と、それに伴うコスト(トレードオフ)を理解しましょう。

1. React.memo(Component, arePropsEqual?)

  • 目的: コンポーネント自体をラップし、propsが変更されていない場合にコンポーネントの再レンダリングをスキップする高階コンポーネント(HOC)を生成します。これは特に、同じpropsで常に同じ結果を描画する純粋なコンポーネントに対して有効です。
  • 比較方法: デフォルトでは、propsオブジェクトの各プロパティを前回と比較します。この比較は浅い比較 (Shallow Comparison) であり、具体的には Object.is を用いて行われます。
  • コスト: レンダリングのたびに前回のpropsと新しいpropsの比較処理が発生します。propsの数が多い、または比較関数(arePropsEqual)が複雑な場合、比較自体のコストが無視できなくなる可能性があります。また、前回のpropsを保持するためのメモリも消費します。
  • 注意: メモ化はパフォーマンス最適のアプローチであり、Reactが常にレンダリングをスキップすることを保証するものではありません

https://ja.react.dev/learn/keeping-components-pure

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Object/is

import { memo, useState, useMemo, useCallback } from 'react'; // useStateなどをインポート

// propsが浅い比較で等しければ再レンダリングをスキップ
const MemoizedButton = memo(function Button({ onClick, children }) {
  console.log('Rendering Button:', children);
  return <button onClick={onClick}>{children}</button>;
});

2. useMemo(createFunction, deps)

  • 目的: 計算結果(値) をメモ化します。依存配列 deps の要素が前回のレンダリング時と Object.is による比較で変更されていない場合、createFunction を再実行せず、キャッシュされた値を返します。
  • 比較方法: 依存配列 deps の各要素を Object.is で浅く比較します。
  • コスト: レンダリングのたびに依存配列の比較処理が発生します。キャッシュされた値と依存配列を保持するためのメモリを消費します。
  • ユースケース: 計算コストの高い関数の結果、またはReact.memoでラップされたコンポーネントに渡すオブジェクトや配列の参照を安定させたい場合。

function calculateExpensiveValue(count) {
  console.log('Calculating expensive value...');
  // ... 時間のかかる計算 ...
  return count * 2;
}

function MyComponent({ count }) {
  // countが変わらない限り再計算されない
  const expensiveValue = useMemo(() => calculateExpensiveValue(count), [count]);
  return <div>Expensive Value: {expensiveValue}</div>;
}

3. useCallback(callbackFunction, deps)

  • 目的: 関数自体 をメモ化します。依存配列 deps の要素が前回のレンダリング時と Object.is による比較で変更されていない場合、callbackFunction を再生成せず、キャッシュされた関数インスタンスを返します。
  • 比較方法: 依存配列 deps の各要素を Object.is で浅く比較します。
  • コスト: レンダリングのたびに依存配列の比較処理が発生します。キャッシュされた関数と依存配列を保持するためのメモリを消費します。
  • ユースケース: React.memo化された子コンポーネントに関数を渡す場合や、useEffect等の依存配列安定化のために適用します。
  • 内部実装: useCallback(fn, deps) は概念的に useMemo(() => fn, deps) と等価です。

// MemoizedButtonコンポーネントの定義は上記を想定

function ParentComponent() {
  const [count, setCount] = useState(0);

  // countが変わらない限り、handleClickは同じ参照を維持
  // (ただし、depsが空の場合、countの値は初期値で固定される点に注意)
  const handleClick = useCallback(() => {
    console.log('Button clicked! Initial count was:', count);
    // 最新のcountを使いたい場合は関数型更新を使うか、depsにcountを含める
    // setCount(currentCount => currentCount + 1);
  }, []); // depsが空

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Increment: {count}</button>
      {/* handleClickの参照が安定しているため、MemoizedButtonの再レンダリングが抑制される */}
      <MemoizedButton onClick={handleClick}>Click Me</MemoizedButton>
    </div>
  );
}

メモ化APIの落とし穴と対策

メモ化APIは便利ですが、意図せず効果がなかったり、バグの原因になったりすることもあります。よくある落とし穴とその対策を理解しておきましょう。

1. React.memo と非プリミティブなProps

  • 落とし穴: React.memo は浅い比較しか行わないため、オブジェクト、配列、インライン関数などをpropsとして渡すと、親が再レンダリングするたびに新しい参照が生成され、メモ化が無効になります。
    // 問題のあるパターン
    function Parent() {
      const [count, setCount] = useState(0);
      // 毎回新しい参照が生成される
      const data = { value: count };
      const handleClick = () => console.log('clicked');
      return (
        <div>
          <button onClick={() => setCount(c => c + 1)}>Update Parent</button>
          {/* data と handleClick が毎回変わるため、常に再レンダリング */}
          <MemoizedChild data={data} onClick={handleClick} />
        </div>
      );
    }
    
  • 対策: 親コンポーネントで useMemouseCallback を使い、これらのpropsの参照を安定させます。
    // 対策
    function Parent() {
      const [count, setCount] = useState(0);
      // count が変わらない限り、同じ参照を返す
      const data = useMemo(() => ({ value: count }), [count]);
      // 常に同じ参照を返す
      const handleClick = useCallback(() => console.log('clicked'), []);
      return (
        <div>
          <button onClick={() => setCount(c => c + 1)}>Update Parent</button>
          {/* count が変わらない限り再レンダリングされない */}
          <MemoizedChild data={data} onClick={handleClick} />
        </div>
      );
    }
    

2. useCallback/useMemo の依存配列の問題

  • 落とし穴1 (指定漏れ): 依存配列を省略すると、毎回新しい値/関数が生成され、メモ化されません (参考)。
  • 落とし穴2 (不足): 依存配列に必要な値を含めないと、関数/計算が古い値を参照し続ける(Stale Closure)バグが発生します。

https://ja.react.dev/reference/react/useCallback#every-time-my-component-renders-usecallback-returns-a-different-function

// 問題のあるパターン (Stale Closure)
function Counter() {
  const [count, setCount] = useState(0);
  // count を依存配列に含めていないため、logCount は常に初期値 0 を参照
  const logCount = useCallback(() => {
    console.log('Current count is (stale):', count);
  }, []); // 依存配列が空
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={logCount}>Log Count (Stale)</button>
    </div>
  );
}
  • 対策: ESLintの react-hooks/exhaustive-deps ルールや Biomecorrectness/useExhaustiveDependencies ルールを有効にし、依存配列を常に正しく保ちます。これにより、依存配列の不足による Stale Closure を静的に検出できます。意図的に依存配列から値を除外する場合は、useState の関数型更新useRef で最新の値を取得するなどの代替策を検討します。

https://biomejs.dev/ja/linter/rules/use-exhaustive-dependencies/

// 対策パターン (Stale Closure 回避)
function Counter() {
  const [count, setCount] = useState(0);
  // 1. 依存配列に含める
  const logCountCorrectDeps = useCallback(() => {
    console.log('Current count is (correct deps):', count);
  }, [count]);
  // 2. 関数型更新を使う (setCount の例)
  const increment = useCallback(() => {
    setCount(currentCount => currentCount + 1); // 依存配列不要
  }, []);
  return (
     <div>
       <p>Count: {count}</p>
       <button onClick={increment}>Increment</button>
       <button onClick={logCountCorrectDeps}>Log Count (Correct)</button>
     </div>
  );
}

3. JSX要素の参照 (children やネスト)

  • 落とし穴: JSX要素 (<div> など) もオブジェクトであり、レンダリングごとに新しい参照を持ちます。そのため、children として渡したり、メモ化コンポーネント内で直接 <InnerMemo /> のように記述したりすると、親コンポーネントのメモ化を破壊します。
    const InnerMemo = memo(() => {
      console.log("Rendering InnerMemo");
      return <p>Inner Memo Component</p>;
    });
    
    // 問題のあるパターン
    function Parent() {
      const [count, setCount] = useState(0);
      return (
        <div>
          <button onClick={() => setCount(c => c + 1)}>Update Parent</button>
          {/* MemoizedChild は count が変わらなくても再レンダリングされる */}
          <MemoizedChild data={{value:0}} onClick={()=>{}}>
            <div>Static Content? No, new object every time.</div>
            <InnerMemo /> {/* New element object every time */}
          </MemoizedChild>
        </div>
      );
    }
    
  • 対策: 静的なJSX要素を children やpropsとして渡す場合は、親コンポーネントで useMemo を使ってそのJSX要素自体の参照を安定させます。
    // 対策パターン
    function Parent() {
      const [count, setCount] = useState(0);
      const stableChildren = useMemo(() => <div>Stable Content</div>, []);
      const stableInner = useMemo(() => <InnerMemo />, []);
      return (
        <div>
          <button onClick={() => setCount(c => c + 1)}>Update Parent</button>
          {/* MemoizedChild は count が変わらない限り再レンダリングされない */}
          <MemoizedChild data={{value:0}} onClick={()=>{}}>
            {stableChildren}
            {stableInner}
          </MemoizedChild>
        </div>
      );
    }
    

4. ループ内でのフック呼び出し

  • 落とし穴: フックのルールにより、ループ内で useCallbackuseMemo は呼び出せません。
    // 問題のあるパターン (概念) - 実際にはエラー
    function List({ items }) {
      return items.map(item => {
        // NG: ループ内でフック呼び出しは不可
        const handleClick = useCallback(() => { /* ... */ }, [item]);
        return <Item key={item.id} onClick={handleClick} item={item} />;
      });
    }
    
  • 対策: ループ内の各アイテムをレンダリングする新しいコンポーネントを作成し、そのコンポーネントのトップレベルでフックを使用します (参考)。また、Biomecorrectness/useHookAtTopLevel ルールを有効にすることで、このような誤ったフックの呼び出しを静的に検出できます。

https://biomejs.dev/ja/linter/rules/use-hook-at-top-level/

// 対策パターン
const ListItem = memo(({ item }) => {
  const handleClick = useCallback(() => {
    console.log('Clicked item:', item.id);
  }, [item]);
  console.log('Rendering ListItem:', item.id);
  return (
    <div>
      Item {item.id} <button onClick={handleClick}>Log</button>
    </div>
  );
});

function List({ items }) {
  return items.map(item => <ListItem key={item.id} item={item} />);
}

カスタム比較関数 (arePropsEqual) の注意点

React.memo の第二引数で比較ロジックをカスタマイズできますが、以下のリスクがあります。

// カスタム比較関数の例
const CustomCompareComponent = memo(
  ({ complexData, onAction }) => {
    console.log("Rendering CustomCompareComponent");
    return (
       <div>Data: {complexData.value} <button onClick={onAction}>Action</button></div>
    );
  },
  (prevProps, nextProps) => {
    // complexData のディープ比較 (高コストの可能性)
    const dataEqual = JSON.stringify(prevProps.complexData) === JSON.stringify(nextProps.complexData);
    // 関数 props の参照比較 (必須)
    const actionEqual = prevProps.onAction === nextProps.onAction;
    return dataEqual && actionEqual;
  }
);
  • 高コスト: ディープ比較は比較自体のコストが高く、逆効果になる可能性があります。
  • 比較漏れ: 特に関数propsの比較を忘れると、古い関数を使い続けるバグにつながります。
  • 複雑化: デバッグが困難になります。

カスタム比較関数は、明確なメリットがあり、かつコストを上回ることをプロファイリングで確認した場合にのみ、慎重に使用してください。

まとめ:パフォーマンス最適化への段階的アプローチと未来への展望

Reactアプリケーションのパフォーマンス最適化は、闇雲に行うものではありません。以下の段階的なアプローチを推奨します。

  1. 測定と特定: まずReact DevTools Profiler等でボトルネックを正確に特定します。どのコンポーネントのレンダリングに時間がかかっているか、不要な再レンダリングが発生していないかを確認します。

  2. 設計の最適化: 特定された問題に対し、まずコンポーネントの設計や状態管理の見直しで解決できないか検討します。コンポーネントや状態のスコープを小さく保つ といった基本的な設計原則に従うことで、多くの場合パフォーマンスは改善されます(コンポジション、State Colocation、Contextの見直しなど)。

  3. メモ化の適用(慎重に): 設計改善後もボトルネックが残る場合に限り、コストと効果を理解した上でメモ化API(React.memo, useMemo, useCallback)の適用を検討します。落とし穴を避け、正しく使用することが重要です。

  4. 効果の再測定: 最適化を適用したら、必ず再度測定し、パフォーマンスが実際に改善したか、新たな問題が発生していないかを確認します。

パフォーマンス最適化においては、まず測定と設計の見直しが重要です。メモ化APIは有効な手段となり得ますが、その適用は慎重に行い、常に効果を確認しながら進めていくことが、より良いアプリケーション開発につながります。

さらに、ReactチームはReact Compilerの開発を進めています。React Compilerは、Reactアプリケーションを最適化するために自動的にコードをメモ化します。開発者が手動でuseMemoやuseCallbackを使用する手間を省き、不要な再レンダリングを防ぐことでパフォーマンスを向上させます。DRESS CODEではまだ本番運用できていませんが、安定版のリリースを期待しています!

https://ja.react.dev/learn/react-compiler

GitHubで編集を提案
DRESS CODE TECH BLOG

Discussion