パフォーマンス最適化(memo · useMemo · useCallback · そして React Compiler)
memo / useMemo / useCallback の役割とよくある誤用を整理し、React Compiler 導入後に何が変わり何がそのまま残るかまで整理します。
13章でロジックを再利用する道具であるカスタムフックを扱いました。本章では React アプリを 速く動かす ために使う3つの道具 — React.memo、useMemo、useCallback — を扱います。そして React 19 で導入された React Compiler がこれらの道具の役割をどう変えるかもあわせて整理します。
これらの道具は強力ですが 誤用も多い ので、動作原理と同じくらい いつ使うべきか、いつ使うべきでないか が重要です。31章(パフォーマンス・バンドル・Web Vitals)では本章の道具を実際に計測して決める手順を扱います。本章で道具の基本原理をつかみ、31章で計測に基づく意思決定にまとめる流れです。
まず、React は基本的に速い #
最初に押さえておくべき点です。React は仮想 DOM を使って 実際に変更された部分だけ を DOM に反映するため、ふつうのアプリならば特別な最適化なしでも十分速く動作します。
パフォーマンス最適化を始める前に自問すべきこと:
- 本当に遅いのか?(体感で遅いのか、計測したのか)
- どこが遅いのか?(React DevTools の Profiler で確認)
- その部分を速くするには何をすべきか?
本章で扱う道具は 再レンダリングがあまりに頻繁、あるいはあまりに重く起きて実際に問題になるとき に使うものであり、すべてのコンポーネントに予防的に塗るものではありません。この点を心に刻んでから始めます。
React の再レンダリングモデルのおさらい #
3つの道具の動作を理解するには、再レンダリングがいつ起きるかから整理する必要があります。
state が変わると、そのコンポーネントと、その子もすべて再レンダリングされる。
子が props で受け取った値が同じでも違っても、いったん 子コンポーネントの関数も再度呼び出されます。この動作が高くなければ問題ないのですが、子が重い計算をしていたり、子の子がさらに重い作業をしていたりすると、累積コストが大きくなります。
memo / useMemo / useCallback はすべて、この「不必要な再レンダリング / 再計算」を減らすための道具です。
React.memo — 子の再レンダリングをスキップさせる #
React.memo はコンポーネントを包んで、props が前回と同じなら再レンダリングしないように する道具です。
import { memo } from 'react';
function Heavy({ value }) {
console.log('Heavy レンダリング');
// ... 重い作業 ...
return <div>{value}</div>;
}
export default memo(Heavy);memo で包んだ Heavy は、親が再レンダリングされても value prop が前回と同じであれば 自分自身は再レンダリングせず、前回の結果をそのまま使います。
落とし穴 — オブジェクト / 配列 / 関数 prop は毎回「新しい値」になる #
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 が無意味になります。
これを解決する道具が useMemo と useCallback です。
useMemo — 値のメモ化 #
useMemo は 計算結果を覚えておき、依存が同じなら再計算せず前回の結果をそのまま返します。
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]);items や threshold が変わっていなければ filter を再度走らせません。データが大きく、フィルタ / ソートが重いときに意味のある節約になります。
(2) オブジェクト / 配列の参照安定化(memo と一緒に使うとき)
const config = useMemo(() => ({ color: 'red', size: 'lg' }), []);
return <MemoizedChild config={config} />;memo で包んだ子にオブジェクト / 配列を渡すときに参照を維持するための用途です。
useCallback — 関数のメモ化 #
useCallback は事実上 useMemo の 関数専用のショートハンド です。
const handleClick = useMemo(() => () => setCount(c => c + 1), []);const handleClick = useCallback(() => setCount(c => c + 1), []);アロー関数の中にさらに関数を置く違和感を取り除くための短縮形と見ればよいです。やっていることは「依存が変わらないかぎり、関数の参照を維持する」ことです。
主な用途は memo で包んだ子にハンドラを渡すときです。
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 の子にハンドラを渡すとき |
useMemo と useCallback は 単独ではほとんど無意味 です。memo で包んだ子とペアになるとき に意味が生まれます(または effect の依存として使うとき)。
React Compiler — 自動メモ化の時代 #
React 19 とともに正式トラックに入った React Compiler は、上の3つの道具をコンパイラが自動で適用してくれる仕組みです。定着すれば、memo / useMemo / useCallback を私たちが直接書くことはほぼなくなります。
Compiler がやること #
- コンポーネント関数のコードを静的に解析
- レンダリングのたびに新しく作られるオブジェクト / 配列 / 関数 / 計算結果を識別
- 依存が変わっていない場合に前回の値を再利用するコードをコンパイル結果に自動挿入
その結果、上の Parent 例を次のようにそのまま書いても:
function Parent() {
const [count, setCount] = useState(0);
const config = { color: 'red' }; // コンパイラが自動で安定化
const handleSave = () => console.log('保存'); // 自動で安定化
return (
<>
<button onClick={() => setCount(count + 1)}>+1</button>
<MemoizedChild config={config} onSave={handleSave} />
</>
);
}MemoizedChild が無意味に再レンダリングされません。useMemo / useCallback を手で書いたのと同じ効果です。
Compiler があっても手で扱う境目 #
次の場合は依然として直接 memo / useMemo / useCallback を検討する必要があります。
- Compiler の静的解析が届かない場合 — 動的キーアクセス、eval のようなパターン、外部関数の依存が追跡不能な場合
- コンパイラが意図的に保守的に動作する場合 — 副作用が疑われるコード領域はメモ化をスキップすることがある
memo(Component)の明示的な表現が必要な場合 — ライブラリコードのように他人が持っていって使うコンポーネントは、本人がmemoを明示するほうが安全- effect の依存 —
useEffectの依存にオブジェクト / 関数が入る場合、Compiler が安定化してくれても意図が明確でなければ、直接useMemo/useCallbackで意図を明らかにするほうがよい
要するに、Compiler 導入後も本章の道具の基本原理はそのまま有効 です。ただし手で書く機会が大きく減るだけです。
よくある誤解と落とし穴 #
誤解1. 「これを使えば無条件に速くなる」 #
むしろ逆です。useMemo / useCallback も タダではありません。依存の比較と前回の値の保存にコストがかかります。メモ化の節約 < そのコストならば、かえって遅くなります。
React 公式ドキュメントが明確に推奨する基本姿勢:
基本は使わない。計測して本当に遅いときだけ追加せよ。
誤解2. 「memo を被せれば勝手に再描画されなくなる」 #
上で見たように、オブジェクト / 配列 / 関数 prop を受け取るなら、それらも一緒に安定化しないと効果は出ません。memo を被せて終わりだと、ほとんどの場合無意味です(ただし React Compiler が有効化された環境では、コンパイラが自動で安定化してくれるので効果が生きます)。
誤解3. 「props が同じなら無条件に描かれない」 #
memo は 浅い比較(shallow comparison) をします。オブジェクトの奥深くまでは見ず、最上位のプロパティの参照だけを比較します。毎回新しいオブジェクトが入ってくれば(安定化していなければ)別物と判断します。
また、子コンポーネント自身が useState や useContext で自分の state を持っているなら、props が同じでもその state が変われば当然再レンダリングされます。memo は親から来る再レンダリングを止めるだけです。
では、いつ使うべきか #
次のような状況で意味があります。
- リストの各項目が重いコンポーネント のとき — 1つの項目だけ変わってもすべての項目が再描画されるとコストが大きくなります。
memoで各項目を包み、親から渡すハンドラはuseCallbackで安定化 - 計算コストが本当に高い場合 — 数万個の項目をソート / フィルタ / 集計する作業。
useMemoでキャッシュし、入力が同じなら再計算しないように - effect の依存に関数 / オブジェクトを使う場合 — 毎回新しい参照だと、effect が毎回再実行されます。
useCallback/useMemoで安定化
小さなコンポーネント、軽い計算、静的な画面にはほぼ必要ありません。コードが複雑になるだけです。
計測が先 #
パフォーマンス最適化の最初のステップは常に 計測 です。React DevTools の Profiler タブでレンダリングがどれだけ頻繁に、どれだけ長くかかったかを視覚的に確認できます。
- ブラウザ拡張 React Developer Tools のインストール
- デベロッパーツール → 「Profiler」タブ
- 赤い録画ボタン → 遅く感じる操作を実行 → 停止
- どのコンポーネントがどれだけ長く描かれたかを棒グラフで確認
これを見ずに「ここが遅そう」という直感だけで最適化を始めると、実際のボトルネックはそのままなのにコードだけ複雑になる結果につながりやすいです。
本書の31章(パフォーマンス・バンドル・Web Vitals)では Profiler に加えて、Lighthouse / Core Web Vitals / バンドル分析 で実際の運用環境でユーザーが感じるパフォーマンスを計測する手順を扱います。本章の道具は、その計測のあと「どの道具を使うか」の候補になります。
やってみよう #
大きなリストを扱う例で memo の効果を体感してみます。
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:
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つ)だけが再描画されるのを確認できます。
直接比較してみたいなら:
ListItemのexport default memo(ListItem);をexport default ListItem;に変える → カウンターを押すたびに 1000 個分のレンダリングログuseCallbackを外してonSelect={(id) => setSelected(id)}に変える → memo があっても 1000 個再レンダリング(関数の参照が毎回変わるため)
3つの道具がどう協力するかが一目でわかります。
練習問題 #
- 上の例で React Compiler を有効化してみてください(Vite の場合
@vitejs/plugin-reactにbabel.plugins: [["babel-plugin-react-compiler"]]を追加)。有効化したあとmemoとuseCallbackをすべて取り除いても、カウンターボタンを押したときに 1000 個が再レンダリングされないか確認してみてください。コンパイラが自動でメモ化を適用した結果です。 useMemoが本当に役立つケースを見つける。1万個の項目を毎回ソートするコンポーネントを作り、useMemoなしでは、入力値を変える別の state 変更でもソートが毎回起きることを確認してください。useMemo(() => [...items].sort(...), [items])を適用したあと差を React DevTools Profiler で計測してみます。- effect の依存の安定化。子コンポーネントが親から
configオブジェクトを受け取り、useEffect(() => ..., [config])で処理するケースを作ってみてください。configを親で毎回新しく作ると、子の effect が毎レンダリングで実行されます。useMemoで安定化したあと、effect が1回だけ実行されるのを確認します。
一行まとめ:
React.memoは props が同じならコンポーネントの再レンダリングをスキップする。useMemoは計算結果のキャッシュ、またはオブジェクト / 配列の参照安定化。useCallbackは関数の参照安定化(useMemoの関数専用ショートハンド)。基本は使わない、計測したあと本当に遅いときだけ追加。3つは セットで 使ってこそ効果が出る(memo + 安定化された props)。React 19 の React Compiler が有効化されれば手で書く機会は大きく減るが、基本原理はそのまま有効。
次の章 #
ここまで1ページの中で起きることを扱ってきました。実際の Web アプリは普通 複数の画面 を持ちます。ユーザーがメニューをクリックすると画面が変わり、URL も変わり、戻るボタンも動作しなければなりません。次の 15章 ルーティング概要では、SPA のルーティング概念と React Router の基本的な使い方を見て、本書の4部(モダン Next.js)が持ち込む App Router との比較まで続けて見ていきます。2部 → 4部のつながりをあらかじめ作っておく形です。