React.memo・useMemo・useCallbackの使いどころ
React.memo・useMemo・useCallbackの使いどころ
React.memo、useMemo、useCallback。とりあえず付ければ速くなるだろうと全体にばらまいて、結局コードが複雑になっただけだった…という経験をしたので、整理し直したときのメモです。
それぞれが 何を防ぐためのものか を押さえると、使いどころがだいぶ見えてきました。
前提:再レンダリングは「悪」ではない
まず押さえておきたいのは、Reactの再レンダリングは多くの場合とても速い、ということです。再レンダリングとは「コンポーネント関数をもう一度呼んで、仮想DOMを作り直す」ことで、実際のDOM更新は差分があった箇所だけです。
なので、メモ化は「再レンダリングが重いと 計測で分かった 箇所」に対して使う最適化です。先回りで全体に振りまくものではない、というのが今の理解です。
React.memo:propsが同じなら再描画を飛ばす
React.memo はコンポーネントをラップして、propsが前回と同じなら再レンダリングをスキップ します。親が再描画されても、自分のpropsが変わっていなければ処理を省けます。
const ExpensiveList = React.memo(function ExpensiveList({ items }: { items: Item[] }) {
// itemsが変わらなければ、親の再描画では再計算されない
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.label}</li>
))}
</ul>
)
})
ただし比較は 浅い比較(shallow) です。ここが落とし穴で、propsにオブジェクトや関数を渡していて、それが毎回新しく作られていると、「中身は同じでも参照が違う」ので memo は効きません。自分は最初これで「memo付けたのに効かない」と悩みました。ここで次の2つが必要になります。
useMemo:計算結果をキャッシュする
useMemo は 計算のコストが高い値 をキャッシュします。依存配列が変わらない限り、前回の計算結果を再利用します。
const sorted = useMemo(() => {
// 大量データの並べ替えなど、重い計算
return [...items].sort((a, b) => a.price - b.price)
}, [items])
注意したいのは、軽い計算にuseMemoを付けても無駄 だということです。メモ化自体にも依存配列の比較というコストがあるので、a + b のような計算を包んでもむしろ損です。useMemo が活きるのは「目に見えて重い計算」か、「下流の React.memo に渡すオブジェクトの参照を安定させたいとき」だと思います。
useCallback:関数の参照を安定させる
useCallback は 関数そのものをメモ化 します。コンポーネントは再レンダリングのたびに内部の関数を作り直すので、その関数を子の React.memo に渡していると、毎回参照が変わって memo が無効になります。これを防ぐのが useCallback です。
const handleSelect = useCallback((id: string) => {
setSelected(id)
}, []) // 参照が固定される
return <MemoizedChild onSelect={handleSelect} />
逆に言うと、React.memo していない子に渡す関数を useCallback で包んでも、防げる再描画がないので効果はありません。useCallback は React.memo した子へ関数を渡すとき にこそ意味を持ちます。ここを分かっていないと、付けても何も変わらなくて「あれ?」となります。
3つの関係を整理する
これらは単独ではなくて、組み合わせて初めて効きます。
| API | メモ化するもの | 主な目的 |
|---|---|---|
React.memo | コンポーネント | propsが同じなら再描画を省く |
useMemo | 値(計算結果) | 重い計算・参照の安定 |
useCallback | 関数 | 子に渡す関数の参照を安定させる |
渡すpropsが プリミティブ(文字列・数値・真偽値)だけなら、React.memo 単体でも再描画をスキップできます。値どうしの浅い比較がそのまま一致するからです。問題になるのは オブジェクトや関数を渡すとき で、これらは毎回新しく作られると参照が変わって、浅い比較が崩れて memo が効かなくなります。そこで「子を React.memo で包む → 渡すオブジェクトは useMemo、関数は useCallback で参照を安定させる」という併用が必要になりやすいです。
付けすぎの弊害
メモ化はタダではありません。次のコストを意識しておくとよさそうです。
- 依存配列の比較コストが毎回かかる
- 前回値を保持するためのメモリを使う
- 依存配列の指定ミスで、古い値を握る(stale)バグが起きうる
- コードが読みづらくなる
軽いコンポーネントや計算に予防的に付けるのは、得るものより失うもののほうが大きくなりがちです。まさに「宝の持ち腐れ」ならぬ「付け損」です。
まず計測してから
最適化の鉄則は「まず計測」だと思います。React DevToolsのProfilerで、どのコンポーネントがどれだけ再描画されて、何msかかっているかを確認します。そこで重いと分かった箇所にだけメモ化を入れて、再度計測して効果を確かめる。この順序を守ると、効かない最適化に時間を使わずに済みます。
なお、React Compiler(自動メモ化)の普及が進めば、こうした手作業のメモ化は今後減っていく見込みのようです。それでも「何を防ぐ仕組みか」を理解しておくと、ツールに任せる判断もしやすくなると思います。
まとめ
- 再レンダリング自体は多くの場合速い。メモ化は計測で重いと分かった箇所に使う
React.memo=propsが同じなら再描画スキップ、useMemo=値、useCallback=関数- プリミティブだけなら
React.memo単体で効く。オブジェクトや関数を渡すときはuseMemo/useCallbackの併用が要る - 付けすぎは比較コストとバグの温床。まずProfilerで計測する
各APIの詳しい挙動はReact公式のuseMemoなどのリファレンスにまとまっています。「速くなるはず」ではなく「計測で速くなった」を基準に使い分けるとよさそうです。以上です。