React基礎講座 #14 パフォーマンス最適化 (memo / useMemo / useCallback)

読了 9分

前回はロジックを再利用するツールであるカスタムフックを学びました。今回はReactアプリを 速く動かす ために使われる3つのツール — React.memouseMemouseCallback を扱っていきます。これらのツールは強力ですが、よく誤用 されるため、動作原理と同じくらい いつ使うべきで、いつ使うべきでないか が重要です。

まず、Reactは基本的に速い #

最初に押さえておくべき点です。Reactは仮想DOMを使って 実際に変更された部分だけ をDOMに反映するため、平凡なアプリなら特別な最適化なしでも十分に速く動作します。

パフォーマンス最適化を始める前に自問すべきこと:

  • 本当に遅いのか? (体感で遅いのか、測定したのか)
  • どこが遅いのか? (React DevToolsのProfilerで確認)
  • その部分を速くするには何をすべきか?

この記事で扱うツールは、リレンダリングが頻繁に、または重く起きすぎて実際に問題になっているとき に使うものであって、すべてのコンポーネントに予防的に塗るものではありません。この点を心に刻んで始めましょう。

Reactのリレンダリングモデル復習 #

3つのツールの動作を理解するためには、リレンダリングがいつ起きるのかから整理する必要があります。

stateが変わると、そのコンポーネントとその子たちがすべて再レンダリングされる。

子がpropsで受け取った値が同じであろうと違っていようと、まず 子コンポーネント関数も再度呼び出されます。この動作が高くなければ問題ありませんが、子が重い計算をしたり、子の子がさらに重い作業をしたりすると、累積コストが大きくなる可能性があります。

memo/useMemo/useCallback はすべて、この「不要なリレンダリング/計算」を減らすためのツールです。

React.memo — 子のリレンダリングをスキップさせる #

React.memo はコンポーネントを包んで、propsが以前と同じなら再レンダリングしない ようにしてくれるツールです。

src/Heavy.jsx
import { memo } from 'react';

function Heavy({ value }) {
  console.log('Heavy レンダリング');
  // ... 重い作業 ...
  return <div>{value}</div>;
}

export default memo(Heavy);

memo で包んだ Heavy は、親が再レンダリングされても value propが以前と同じなら 自分自身は再レンダリングせず、以前の結果をそのまま使います。

落とし穴1 — オブジェクト/配列/関数のpropsは毎回「新しい値」になる #

問題のあるコード
function Parent() {
  const [count, setCount] = useState(0);

  const config = { color: 'red' };  // レンダリングのたびに新しいオブジェクト

  return (
    <>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <Heavy config={config} />
    </>
  );
}

親が再レンダリングされるたびに { color: 'red' } というオブジェクトリテラルが 新しく作られます。内容は同じですが参照は毎回違うため、memo が比較したときに「異なるprop」と判断して、子は毎回再レンダリングされます。結果的に memo が無意味になりますね。

これを解決するツールが useMemouseCallback です。

useMemo — 値をメモ化 #

useMemo計算結果を覚えておいて、依存配列が同じなら再計算せず、以前の結果をそのまま返します

src/Parent.jsx
import { useState, useMemo } from 'react';

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

  const config = useMemo(() => ({ color: 'red' }), []);

  return (
    <>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <Heavy config={config} />
    </>
  );
}

useMemo の形は useEffect に似ています。

const result = useMemo(() => 計算関数(), [依存, 配列]);
  • 第1引数: 計算を実行して結果を返す関数
  • 第2引数: 依存配列 — これらの値が同じなら以前の結果を再利用

上の例で config は依存配列が空配列なので、常に同じオブジェクト参照を返します。なので Heavy (memoで包んだもの) は毎回再レンダリングされません。

useMemoは2つの目的で使う #

useMemo の使い方は2つに整理できます。

(1) 高い計算結果のキャッシュ

高い計算を毎回しないようにする
const filtered = useMemo(() => {
  return items.filter(item => item.score > threshold);
}, [items, threshold]);

itemsthreshold が変わっていなければ filter を再度実行しません。データが大きく、フィルタ/ソートが重いときに意味のある節約になります。

(2) オブジェクト/配列の参照安定化 (memoと一緒に使うとき)

オブジェクトpropの安定化
const config = useMemo(() => ({ color: 'red', size: 'lg' }), []);
return <MemoizedChild config={config} />;

memo で包んだ子にオブジェクト/配列を渡すときに参照を維持するための用途です。

useCallback — 関数をメモ化 #

useCallback は事実上 useMemo関数専用の短縮版 です。

useMemoで関数を
const handleClick = useMemo(() => () => setCount(c => c + 1), []);
useCallbackで同じことを
const handleClick = useCallback(() => setCount(c => c + 1), []);

アロー関数の中にもう一つ関数を置く違和感をなくしてくれる短縮形だと思えばよいです。していることは「関数の参照を、依存配列が変わらない限り維持する」ことです。

使い道は主に memo で包んだ子にハンドラを渡すときです。

子がmemoで包まれていて関数をpropsで受け取るとき
function Parent() {
  const [count, setCount] = useState(0);

  const handleSave = useCallback(() => {
    console.log('保存');
  }, []);

  return (
    <>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <MemoizedChild onSave={handleSave} />
    </>
  );
}

useCallback がないとレンダリングのたびに新しい関数が作られて、memo で包んだ MemoizedChild が無意味に再レンダリングされます。

3つのツールの関係整理 #

ツール何をメモ化するかいつ使うか
memoコンポーネント自体親が頻繁にリレンダリングされるが子のpropsはほとんど変わらず、子が重いとき
useMemo計算結果 (値)高い計算、または memo の子に渡すオブジェクト/配列の参照安定化
useCallback関数memo の子にハンドラを渡すとき

useMemouseCallback単独ではほぼ無意味 です。memo で包んだ子とペアになるとき に意味が生まれます (または effect の依存配列として使われるとき)。

よくある誤解と落とし穴 #

誤解1. 「これを使えば必ず速くなる」 #

むしろその逆です。useMemo/useCallbackタダではありません — 依存配列を比較して、以前の値を保管するコストがかかります。メモ化による節約 < そのコストなら、むしろ遅くなります。

React公式ドキュメントが明確に推奨する基本姿勢は:

基本は使わないこと。測定して本当に遅いときだけ追加すること。

誤解2. 「memoだけかぶせれば自動的に再レンダリングされない」 #

上で見たように、オブジェクト/配列/関数のpropsを受け取るなら、それらも一緒に安定化する必要があります。memoだけかぶせて終わりだと、ほとんど無意味です。

誤解3. 「propsだけ同じなら絶対に再レンダリングされない」 #

memo浅い比較 (shallow comparison) をします。オブジェクトの深い内部までは見ず、最上位のプロパティの参照だけを比較します。なので毎回新しいオブジェクトが入ると (安定化していない場合)、別物だと判断されます。

また、子コンポーネント自身が useState や useContext で自分の state を持っているなら、propsが同じでもその state が変わると当然再レンダリングされます。memo は親から来るリレンダリングだけを防いでくれるのです。

では、いつ使うべきか? #

次のような状況で意味があります。

  1. リストの各項目が重いコンポーネント のとき — 一つの項目が変わっただけですべての項目が再描画されるとコストが大きいです。memo で各項目を包み、親から渡すハンドラは useCallback で安定化
  2. 計算コストが本当に高い場合 — たとえば数万個の項目をソート/フィルタ/集計する作業。useMemo でキャッシュして、入力が同じなら再計算しないように
  3. effectの依存配列として関数/オブジェクトを使う場合 — 毎回新しい参照だと effect が毎回再実行されます。useCallback/useMemo で安定化

小さなコンポーネント、軽い計算、静的な画面にはほぼ必要ありません。コードが複雑になるだけです。

ヒント
React 19に導入された React Compiler は、このようなメモ化をコンパイラが自動で適用してくれる実験的なツールです。正式に安定化されれば、memo/useMemo/useCallback を私たちが直接使う場面はほぼなくなる可能性が高いです。ただしその前にも、動作原理を理解していなければ自動で適用された結果をデバッグできないので、この記事の内容は依然として有効です。

測定が先 #

パフォーマンス最適化の最初のステップは、常に 測定 です。React DevToolsの Profiler タブで、レンダリングがどれくらい頻繁に、どれくらい時間がかかっているかを視覚的に確認できます。

  1. ブラウザ拡張 React Developer Tools をインストール
  2. デベロッパーツール → 「Profiler」タブ
  3. 赤い録画ボタン → 遅く見える動作を実行 → 停止
  4. どのコンポーネントがどれくらい長く描画されたかを棒グラフで確認

これを見ずに「ここが遅そう」という直感に頼って最適化を始めると、実際のボトルネックはそのままなのにコードだけ複雑になる結果につながりやすいです。

自分でやってみる #

大きなリストを扱う例で memo の効果を体感してみましょう。

src/ListItem.jsx:

src/ListItem.jsx
import { memo } from 'react';

function ListItem({ item, onSelect }) {
  console.log(`レンダリング: ${item.name}`);
  return (
    <li onClick={() => onSelect(item.id)} style={{ cursor: 'pointer', padding: '4px' }}>
      {item.name}
    </li>
  );
}

export default memo(ListItem);

src/App.jsx:

src/App.jsx
import { useState, useCallback } from 'react';
import ListItem from './ListItem';

const ITEMS = Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `項目 ${i}` }));

function App() {
  const [selected, setSelected] = useState(null);
  const [count, setCount] = useState(0);

  const handleSelect = useCallback((id) => setSelected(id), []);

  return (
    <div style={{ padding: '16px' }}>
      <p>選択された項目: {selected ?? 'なし'}</p>
      <p>関係のないカウンター: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>カウンター +1</button>

      <ul style={{ maxHeight: '300px', overflow: 'auto', border: '1px solid #ccc' }}>
        {ITEMS.map(item => (
          <ListItem key={item.id} item={item} onSelect={handleSelect} />
        ))}
      </ul>
    </div>
  );
}

export default App;

コンソールを開いてカウンターボタンを押すと、ListItem 1000個すべてが再レンダリングされず、初回マウント時に1回ずつログが出力された後は、カウンターを増やしても追加のログがほとんどないはずです。項目をクリックすると、その項目1つ (または直前に選択された項目までで2つ) 程度だけ再描画されることが確認できます。

直接比較してみたければ:

  • ListItemexport default memo(ListItem);export default ListItem; に変える → カウンターを押すたびに1000個のレンダーログ
  • useCallback を外して onSelect={(id) => setSelected(id)} に変える → memoがあっても1000個レンダー (関数参照が毎回変わるため)

3つのツールがどう協力するかが一目でわかります。

まとめ #

今回の記事ではReactのパフォーマンス最適化ツール3つを扱いました。

  • React.memo — propsが同じならコンポーネントのリレンダリングをスキップ
  • useMemo — 計算結果のキャッシュ (またはオブジェクト/配列の参照安定化)
  • useCallback — 関数参照の安定化 (useMemo の関数専用短縮形)

最も重要なマインドセット:

  • 基本は使わないこと — 測定後に本当に遅いときだけ追加
  • 3つは セットで 使ってこそ効果が出る (memo + 安定化されたprops)
  • React DevTools Profilerで測定から

これまでは1ページの中で起きることを扱ってきましたが、実際のWebアプリは普通 複数の画面 を持っています。ユーザーがメニューをクリックすると画面が変わり、URLも変わり、戻るボタンも動作する必要がありますね。次の記事であり、このシリーズの最後である「React基礎講座 #15 ルーティング概要」では、SPAのルーティング概念とReact Routerの基本的な使い方を見ていきます。

X