0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Qiita Advent Calender 2025企画】React Hooks を Hackしよう!【Part11: useMemoをふかぼろう!】

Posted at

useMemoレンダー間で計算結果をキャッシュするための React フックです。高コストな計算の再実行を防ぎ、パフォーマンスを最適化するために使用されます。

const cachedValue = useMemo(calculateValue, dependencies)

💡 React Compiler について
React Compiler は値や関数の自動的なメモ化を行うことで、手作業による useMemo 呼び出しの必要性を軽減します。将来的には、コンパイラによる自動メモ化が主流になる可能性があります。

1. なぜ useMemo が必要か

1.1 再レンダーと計算コストの問題

React では、再レンダーが発生するたびにコンポーネント関数全体が再実行されます。

function TodoList({ todos, tab, theme }) {
  // ❌ 毎回のレンダーで filterTodos が実行される
  const visibleTodos = filterTodos(todos, tab);
  
  return (
    <div className={theme}>
      {visibleTodos.map(todo => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </div>
  );
}

問題: theme が変わっただけでも、filterTodos が再実行されてしまいます。巨大な配列をフィルタリングする場合、これはパフォーマンスの無駄です。

1.2 useMemo が解決すること

useMemo を使うと、依存値が変化しない限り、以前の計算結果を再利用できます:

function TodoList({ todos, tab, theme }) {
  // ✅ todos か tab が変わった時だけ再計算
  const visibleTodos = useMemo(
    () => filterTodos(todos, tab),
    [todos, tab]
  );
  
  return (
    <div className={theme}>
      {visibleTodos.map(todo => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </div>
  );
}

結果: theme が変わっても、todostab が同じなら計算はスキップされます。

1.3 useMemo の API

const cachedValue = useMemo(calculateValue, dependencies)
引数 説明
calculateValue キャッシュしたい値を計算する純関数。引数を取らず、任意の型の値を返す
dependencies calculateValue 内で参照されるすべてのリアクティブ値の配列

返り値:

  • 初回レンダー: calculateValue() の結果
  • 次回以降: 依存値が変化していなければキャッシュされた値、変化していれば再計算した値

1.4 メモ化 (Memoization) とは

メモ化は、同じ入力に対して同じ出力を返す関数の結果をキャッシュする最適化テクニックです。useMemo という名前はこの概念に由来しています。

1.5 似ているフックとの比較

useMemo と混同しやすいフックがいくつかあります。それぞれの違いを理解しましょう。

useMemo vs useCallback

項目 useMemo useCallback
目的 をメモ化 関数をメモ化
返り値 計算結果(任意の型) 関数
使用場面 高コストな計算結果のキャッシュ 子コンポーネントに渡すコールバック
// useMemo: 計算結果をキャッシュ
const sortedItems = useMemo(() => items.sort(), [items]);

// useCallback: 関数自体をキャッシュ
const handleClick = useCallback(() => {
  console.log(items);
}, [items]);

// 実は useCallback は useMemo の糖衣構文
// useCallback(fn, deps) === useMemo(() => fn, deps)

useMemo vs React.memo

項目 useMemo React.memo
対象 フック(値のメモ化) HOC(コンポーネントのメモ化)
使い方 コンポーネント内で呼び出す コンポーネントをラップする
スキップするもの 計算の再実行 コンポーネントの再レンダー
// useMemo: 値の計算をスキップ
function Parent({ items }) {
  const processed = useMemo(() => expensiveCalc(items), [items]);
  return <Child data={processed} />;
}

// React.memo: コンポーネントの再レンダーをスキップ
const Child = memo(function Child({ data }) {
  return <div>{data}</div>;
});

// 💡 両方を組み合わせて使うことが多い

useMemo vs useState

項目 useMemo useState
目的 派生データのキャッシュ 独立した状態の管理
更新トリガー 依存値の変化 setState の呼び出し
再レンダー 発生しない 発生する
// ❌ useState で派生データを管理(アンチパターン)
function Component({ items }) {
  const [filtered, setFiltered] = useState([]);
  
  useEffect(() => {
    setFiltered(items.filter(i => i.active)); // 不要な再レンダー!
  }, [items]);
}

// ✅ useMemo で派生データを計算
function Component({ items }) {
  const filtered = useMemo(
    () => items.filter(i => i.active),
    [items]
  );
}

useMemo vs useRef

項目 useMemo useRef
目的 計算結果のキャッシュ 値の永続化(レンダー間で保持)
再計算 依存値が変わると再計算 再計算しない(手動で更新)
用途 派生データ DOM参照、前回値の保持、タイマーID等
// useMemo: 依存値が変わると自動で再計算
const doubled = useMemo(() => value * 2, [value]);

// useRef: 値を手動で管理、再レンダーしない
const prevValue = useRef(value);
useEffect(() => {
  prevValue.current = value; // 手動で更新
}, [value]);

1.6 重要な注意点

ルール 説明
パフォーマンス最適化のみ useMemo を外してもコードが動作すべき
トップレベルでのみ呼び出し ループや条件分岐内では使用不可
⚠️ キャッシュは破棄されうる 特定の状況でReactがキャッシュを破棄することがある
⚠️ Strict Mode で2回実行 開発時のみ、純粋性チェックのため
function Component({ items }) {
  // ❌ ループ内で呼び出せない
  items.map(item => {
    const data = useMemo(() => calculate(item), [item]); // エラー!
  });
  
  // ✅ トップレベルで呼び出す
  const processedItems = useMemo(() => {
    return items.map(item => calculate(item));
  }, [items]);
}

2. useMemo の内部構造を徹底解剖

2.0 全体像: useMemo の処理フロー

🎣 useMemo(フック呼び出し)
   ↓
📝 初回: mountMemo / 更新: updateMemo
   ↓
📋 Hook オブジェクトに [計算結果, 依存配列] を保存
   ↓
🔍 更新時: areHookInputsEqual で依存配列を比較
   ↓
✅ 同じなら: キャッシュを返す
   ↓
🔄 違うなら: 再計算して新しい値をキャッシュ

2.1 エントリポイント: packages/react/src/ReactHooks.js

// packages/react/src/ReactHooks.js

export function useMemo<T>(
  create: () => T,
  deps: Array<mixed> | void | null,
): T {
  const dispatcher = resolveDispatcher();
  return dispatcher.useMemo(create, deps);
}

引用元: packages/react/src/ReactHooks.js

useMemo が呼ばれると、現在のディスパッチャの useMemo メソッドが呼び出されます。ディスパッチャはレンダリングのフェーズ(マウント時 or 更新時)によって異なる実装を持ちます。

2.2 コア実装: mountMemoupdateMemo

初回レンダー時の処理 (mountMemo)

// packages/react-reconciler/src/ReactFiberHooks.js

function mountMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null,
): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const nextValue = nextCreate();
  if (shouldDoubleInvokeUserFnsInHooksDEV) {
    setIsStrictModeForDevtools(true);
    try {
      nextCreate();
    } finally {
      setIsStrictModeForDevtools(false);
    }
  }
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

引用元: packages/react-reconciler/src/ReactFiberHooks.js#L2917-L2933

ポイント:

  1. mountWorkInProgressHook() で新しい Hook オブジェクトを作成
  2. nextCreate() を呼び出して計算を実行
  3. Strict Mode では nextCreate() が2回呼ばれる(純粋性チェック)
  4. [計算結果, 依存配列] のタプルを memoizedState に保存

更新時の処理 (updateMemo)

// packages/react-reconciler/src/ReactFiberHooks.js

function updateMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null,
): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  // Assume these are defined. If they're not, areHookInputsEqual will warn.
  if (nextDeps !== null) {
    const prevDeps: Array<mixed> | null = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevState[0];
    }
  }
  const nextValue = nextCreate();
  if (shouldDoubleInvokeUserFnsInHooksDEV) {
    setIsStrictModeForDevtools(true);
    try {
      nextCreate();
    } finally {
      setIsStrictModeForDevtools(false);
    }
  }
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

引用元: packages/react-reconciler/src/ReactFiberHooks.js#L2935-L2962

ポイント:

  1. updateWorkInProgressHook() で既存の Hook オブジェクトを取得
  2. areHookInputsEqual で前回の依存配列と比較
  3. 依存配列が同じ場合: prevState[0](キャッシュ)を返す
  4. 依存配列が異なる場合: 再計算して新しい値をキャッシュ

2.3 依存配列の比較: areHookInputsEqual

// packages/react-reconciler/src/ReactFiberHooks.js

function areHookInputsEqual(
  nextDeps: Array<mixed>,
  prevDeps: Array<mixed> | null,
): boolean {
  if (__DEV__) {
    if (ignorePreviousDependencies) {
      // Only true when this component is being hot reloaded.
      return false;
    }
  }

  if (prevDeps === null) {
    if (__DEV__) {
      console.error(
        '%s received a final argument during this render, but not during ' +
          'the previous render. Even though the final argument is optional, ' +
          'its type cannot change between renders.',
        currentHookNameInDev,
      );
    }
    return false;
  }

  if (__DEV__) {
    // Don't bother comparing lengths in prod because these arrays should be
    // passed inline.
    if (nextDeps.length !== prevDeps.length) {
      console.error(
        'The final argument passed to %s changed size between renders. The ' +
          'order and size of this array must remain constant.\n\n' +
          'Previous: %s\n' +
          'Incoming: %s',
        currentHookNameInDev,
        `[${prevDeps.join(', ')}]`,
        `[${nextDeps.join(', ')}]`,
      );
    }
  }
  // $FlowFixMe[incompatible-use] found when upgrading Flow
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    // $FlowFixMe[incompatible-use] found when upgrading Flow
    if (is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;
}

引用元: packages/react-reconciler/src/ReactFiberHooks.js#L454-L500

ポイント:

  1. 各依存値を Object.is(コード中の is)で比較
  2. 開発モードでは配列長の変化を警告
  3. ホットリロード時は強制的に false を返す

2.4 Hook オブジェクトの構造

// packages/react-reconciler/src/ReactFiberHooks.js

function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,  // useMemo では [計算結果, 依存配列]
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null,           // 次の Hook へのリンク
  };

  if (workInProgressHook === null) {
    // This is the first hook in the list
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

引用元: packages/react-reconciler/src/ReactFiberHooks.js#L980-L1000

ポイント:

  • Hook はリンクドリストとして管理される
  • useMemo の場合、memoizedState[計算結果, 依存配列] のタプル
  • これがフックの呼び出し順序を保つ理由(条件分岐内で使えない理由)

2.5 処理フローの図解

2.6 ディスパッチャの切り替え

React は、フック呼び出し時のコンテキストに応じて異なるディスパッチャを使用します:

// packages/react-reconciler/src/ReactFiberHooks.js

const HooksDispatcherOnMount: Dispatcher = {
  // ... 他のフック
  useMemo: mountMemo,
  // ...
};

const HooksDispatcherOnUpdate: Dispatcher = {
  // ... 他のフック
  useMemo: updateMemo,
  // ...
};

引用元: packages/react-reconciler/src/ReactFiberHooks.js#L3904

2.7 なぜ条件分岐内で使えないのか

Hook はリンクドリストとして順番に管理されるため、呼び出し順序が変わると問題が発生します:

function Component({ showExtra }) {
  const value1 = useMemo(() => compute1(), []); // Hook #1
  
  // ❌ これは禁止!
  if (showExtra) {
    const value2 = useMemo(() => compute2(), []); // Hook #2 (条件付き)
  }
  
  const value3 = useMemo(() => compute3(), []); // Hook #3
  
  // showExtra が true → false に変わると:
  // 前回: Hook #1 → Hook #2 → Hook #3
  // 今回: Hook #1 → Hook #3 (Hook #2 がない!)
  // → memoizedState の対応がずれてバグになる
}

3. ユースケース

3.1 高コストな計算の最適化

最も基本的なユースケースは、計算コストが高い処理の結果をキャッシュすることです。

function SearchResults({ query, items }) {
  // 大量のアイテムをフィルタリング・ソート
  const filteredItems = useMemo(() => {
    console.log('Filtering items...');
    return items
      .filter(item => item.name.includes(query))
      .sort((a, b) => a.name.localeCompare(b.name));
  }, [query, items]);

  return (
    <ul>
      {filteredItems.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

効果: query または items が変わった時だけフィルタリングが実行されます。

3.2 memo と組み合わせた再レンダー最適化

React.memo でラップした子コンポーネントに渡す値をメモ化することで、不要な再レンダーを防ぎます。

import { memo, useMemo } from 'react';

// memo でラップされた子コンポーネント
const ExpensiveList = memo(function ExpensiveList({ items }) {
  console.log('ExpensiveList rendered');
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>{item.text}</li>
      ))}
    </ul>
  );
});

function TodoList({ todos, filter, theme }) {
  // ✅ useMemo で参照の同一性を保証
  const filteredTodos = useMemo(() => {
    return todos.filter(todo => 
      filter === 'all' || 
      (filter === 'completed' && todo.completed) ||
      (filter === 'active' && !todo.completed)
    );
  }, [todos, filter]);

  return (
    <div className={theme}>
      {/* theme が変わっても ExpensiveList は再レンダーされない */}
      <ExpensiveList items={filteredTodos} />
    </div>
  );
}

3.3 useEffect の依存配列での使用

エフェクトの依存配列に渡すオブジェクトをメモ化して、不要な再実行を防ぎます。

function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  // ✅ roomId が変わった時だけ新しいオブジェクトを作成
  const options = useMemo(() => ({
    serverUrl,
    roomId,
  }), [serverUrl, roomId]);

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [options]); // options の参照が安定しているので安全

  return <h1>Welcome to {roomId}!</h1>;
}

より良い解決策: オブジェクトをエフェクト内で作成する

function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  useEffect(() => {
    // ✅ useMemo なしでも OK!
    const options = {
      serverUrl,
      roomId,
    };
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [serverUrl, roomId]);

  return <h1>Welcome to {roomId}!</h1>;
}

3.4 他のフックの依存値のメモ化

ネストした useMemo で依存値自体をメモ化します。

function Dropdown({ allItems, text }) {
  // ❌ 毎レンダーで新しいオブジェクトが作られる
  // const searchOptions = { matchMode: 'whole-word', text };

  // ✅ text が変わった時だけ新しいオブジェクトを作成
  const searchOptions = useMemo(() => ({
    matchMode: 'whole-word',
    text,
  }), [text]);

  const visibleItems = useMemo(() => {
    return searchItems(allItems, searchOptions);
  }, [allItems, searchOptions]);

  return <ItemList items={visibleItems} />;
}

さらに良い解決策: 一つの useMemo にまとめる

function Dropdown({ allItems, text }) {
  const visibleItems = useMemo(() => {
    // ✅ searchOptions を useMemo 内で作成
    const searchOptions = { matchMode: 'whole-word', text };
    return searchItems(allItems, searchOptions);
  }, [allItems, text]);

  return <ItemList items={visibleItems} />;
}

4. useMemo と useCallback の関係

useCallback は関数をメモ化するための専用フックですが、実は useMemo で実装できます。

// この2つは完全に等価
const memoizedCallback = useCallback(
  (a, b) => a + b,
  [dep1, dep2]
);

const memoizedCallback = useMemo(
  () => (a, b) => a + b,
  [dep1, dep2]
);

内部実装を見ると:

// packages/react-reconciler/src/ReactFiberHooks.js

function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  hook.memoizedState = [callback, nextDeps];  // useMemo と同じ構造!
  return callback;
}

引用元: packages/react-reconciler/src/ReactFiberHooks.js#L2897-L2903

useCallback(fn, deps)useMemo(() => fn, deps) の糖衣構文(シンタックスシュガー)です。

5. トラブルシューティング

5.1 毎回再計算されてしまう

原因1: 依存配列を忘れている

// ❌ 依存配列がないと毎回再計算
const value = useMemo(() => compute(a, b));

// ✅ 依存配列を渡す
const value = useMemo(() => compute(a, b), [a, b]);

原因2: オブジェクトや配列が毎回新しく作られている

function Component({ items }) {
  // ❌ config は毎回新しいオブジェクト
  const config = { threshold: 10 };
  
  const result = useMemo(() => {
    return process(items, config);
  }, [items, config]); // config が毎回変わるので意味がない!
  
  // ✅ 解決策1: config もメモ化
  const config = useMemo(() => ({ threshold: 10 }), []);
  
  // ✅ 解決策2: useMemo 内で config を作成
  const result = useMemo(() => {
    const config = { threshold: 10 };
    return process(items, config);
  }, [items]);
}

5.2 undefined が返される

アロー関数でオブジェクトを返す際の構文エラー:

// ❌ {} がブロックとして解釈される
const options = useMemo(() => {
  matchMode: 'whole-word',
  text: text
}, [text]);

// ✅ 括弧で囲む
const options = useMemo(() => ({
  matchMode: 'whole-word',
  text: text
}), [text]);

// ✅ 明示的に return する
const options = useMemo(() => {
  return {
    matchMode: 'whole-word',
    text: text
  };
}, [text]);

5.3 Strict Mode で2回実行される

これは仕様です。純粋でない関数を検出するためのチェックです。

const value = useMemo(() => {
  // ❌ 副作用があると問題が発覚する
  items.push({ id: 'new' }); // ミューテーション!
  return items;
}, [items]);

// ✅ 純粋な計算のみを行う
const value = useMemo(() => {
  return [...items, { id: 'new' }]; // 新しい配列を作成
}, [items]);

5.4 ループ内で使いたい

フックはトップレベルでしか呼び出せません。コンポーネントに切り出しましょう。

// ❌ ループ内で useMemo は使えない
function ReportList({ items }) {
  return (
    <ul>
      {items.map(item => {
        const data = useMemo(() => calculate(item), [item]); // エラー!
        return <li key={item.id}>{data}</li>;
      })}
    </ul>
  );
}

// ✅ 子コンポーネントに切り出す
function ReportList({ items }) {
  return (
    <ul>
      {items.map(item => (
        <ReportItem key={item.id} item={item} />
      ))}
    </ul>
  );
}

function ReportItem({ item }) {
  const data = useMemo(() => calculate(item), [item]); // ✅ OK
  return <li>{data}</li>;
}

// ✅ または memo でコンポーネント自体をメモ化
const ReportItem = memo(function ReportItem({ item }) {
  const data = calculate(item);
  return <li>{data}</li>;
});

6. まとめ

6.1 使うべき場合

シナリオ 理由
計算に時間がかかる 1ms以上かかる処理はメモ化の価値あり
memo でラップしたコンポーネントに渡す 参照の同一性が必要
他のフックの依存値として使う 不要な再実行を防ぐ
大きな配列やオブジェクトの変換 毎回新しい参照を作らない

6.2 使わなくてよい場合

シナリオ 理由
単純な計算 オーバーヘッドの方が大きい
プリミティブ値の計算 参照の同一性は関係ない
レンダーごとに変わるべき値 メモ化の意味がない

6.3 計算コストの測定方法

function TodoList({ todos, tab }) {
  console.time('filter todos');
  const visibleTodos = useMemo(() => {
    return filterTodos(todos, tab);
  }, [todos, tab]);
  console.timeEnd('filter todos');
  
  // ...
}

1ms 以上かかる場合は、メモ化の価値があります。

useMemo は React のパフォーマンス最適化における重要なツールです:

  1. 内部的には Hook オブジェクトに [計算結果, 依存配列] を保存
  2. 更新時areHookInputsEqualObject.is による比較を行う
  3. 依存配列が同じ ならキャッシュを返し、異なれば 再計算

適切に使用することで、アプリケーションのパフォーマンスを向上させることができますが、過度な使用は逆効果になることもあります。計測に基づいて、必要な場所にのみ適用しましょう。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?