多くのReactパフォーマンスに関するチュートリアルは、同じ間違ったアドバイスで終わります。「すべてをuseMemoとuseCallbackでラップしておけばいい」というものです。もしそのアドバイスに従っているなら、おそらくアプリケーションをより遅くしてしまっています。これらのフックは無料ではありません。それぞれがメモリを割り当て、依存関係を比較し、キャッシュされた値を保存します。目的なく使用すると、最適化ではなくオーバーヘッドになってしまいます。
本質的に何が重要なのか、整理していきましょう。
各フックが実際に行っていること
useMemoは値を記憶します。重い処理を行う関数を渡し、その結果を受け取ります。次のレンダリング時に依存関係が変わっていなければ、Reactは計算をスキップし、以前の結果を返します。
useCallbackは関数を記憶します。関数を実行してくれるわけではありません。依存関係が変わらない限り、レンダリング間で同じ関数のインスタンスを単に返すだけです。
これが決定的な違いです。一方は計算された値をキャッシュし、もう一方は参照をキャッシュします。これらを混同すると、最適化されているように見えて、実際にはラップしていないコードと全く同じ挙動をしながら、余計なメモリを消費するコードになってしまいます。
なぜ関数の同一性がツリーを壊すのか
コンポーネントが再レンダリングされるとき、Reactは関数本体全体を再度実行します。すべての変数が再作成されます。インライン関数はすべて、メモリ上の新しいアドレスを取得します。
JavaScriptでは、全く同じロジックを持つ2つの関数は等価ではありません。() => {} === () => {} は false と評価されます。このルールはオブジェクトや配列にも適用されます。親コンポーネントが handleSubmit を定義して子に渡している場合、その子はレンダリングのたびに新しいプロップを受け取ります。たとえ子が React.memo でラップされていても、新しい関数が古い関数と同じことをしているかどうかを判断することはできません。参照が変わったため、子は再レンダリングされます。
これこそが useCallback が解決するために作られた根本的な問題です。これは速度の問題ではなく、安定性の問題なのです。
useMemo が真価を発揮する場面
客観的に見てコストが高く、測定可能なラグが発生する場合に useMemo が必要になります。
大規模なデータセットのフィルタリングを考えてみましょう。数万行のテーブルと検索入力がある場合、コンポーネント内で次のようなコードを書くかもしれません。
const visibleRows = rows.filter(r => r.name.includes(query));
useMemo がないと、そのループはレンダリングのたびに実行されます。ユーザーがサイドバーを切り替えるボタンをクリックすると、親が再レンダリングされ、rows と query が変わっていなくてもフィルタリングが再度実行されます。大規模なデータセットでは、そのカクつきが目に見えてしまいます。
useMemo は結果を固定することで、これを解決します。
const visibleRows = useMemo(() => {
return rows.filter(r => r.name.includes(query));
}, [rows, query]);
これで、React は依存関係が実際に変わったときにのみ、そのフィルタリングを再実行するようになります。
同じロジックは、複雑な数学的計算、APIレスポンスをチャートに適した形式に変換する処理、あるいは常に再計算されてしまうような状態の導出にも適用できます。
もう一つ、あまり気づかれにくいユースケースがあります。オブジェクトや配列をローカルで作成し、それを useEffect の依存関係配列に含めている場合、誤ってレンダリングのたびにそのエフェクトをトリガーしてしまう可能性があります。インラインのオブジェクトや配列は毎回新しいアイデンティティ(参照)を持つため、エフェクトは依存関係が変わったと判断して再度実行されます。useMemo でそのオブジェクトをメモ化することで、参照を安定させ、基礎となるデータが実際に変わったときにのみエフェクトを実行させることができます。
useCallback が必要になる場面
useCallback が最も重要になるのは、React.memo で最適化された子コンポーネントにハンドラーを渡すときです。
カウンターを保持する親コンポーネントを想像してください。そのコンポーネントは、コストの高い子リストもレンダリングします。
function Parent() {
const [count, setCount] = useState(0);
const handleItemClick = (id) => {
console.log(id);
};
return (
<div>
<button onClick={() => setCount(c + 1)}>{count}</button>
<ExpensiveList onItemClick={handleItemClick} />
</div>
);
}
count が変わるたびに Parent が再レンダリングされます。新しい handleItemClick が作成されます。ExpensiveList は新しいプロップの参照を受け取るため、それも再レンダリングされます。もし ExpensiveList が React.memo でラップされていても、関数のプロップが変わってしまうため、そのメモ化は完全に無駄になってしまいます。
useCallback は参照を保持します。
const handleItemClick = useCallback((id) => {
console.log(id);
}, []);
これで、ExpensiveList は本当に必要なときにのみ再レンダリングされるようになります。
もう一つの重要な状況は useEffect に関するものです。エフェクトがコンポーネント内で定義された関数を購読しており、その関数がレンダリングのたびにアイデンティティ(参照)を変える場合、エフェクトは繰り返しクリーンアップと再購読を繰り返します。関数をメモ化することで、エフェクトを安定させることができます。
依存関係配列の罠と Stale Closures
両方のフックは依存関係配列に依存しており、ここにほとんどのバグが潜んでいます。
依存配列から変数を省略すると、メモ化された関数や値はその変数の古いバージョンをクロージャとして保持してしまいます。これが stale closure(古いクロージャ)です。UIには最新のデータが表示されていても、コールバックは3回前のレンダリング時のステートを見続けている、といったことが起こり得ます。解決策は単純ですが、コードレビューで見落としがちです。フック内で使用されている、変更される可能性のあるすべての値を含めてください。
react-hooks/exhaustive-deps ESLintルールを実行しましょう。これにより、明らかな漏れを検知できます。しかし、ルールを機械的に扱うのではなく、なぜ各依存関係が重要なのかを理解するようにしてください。
過剰な最適化に潜む隠れたコスト
初心者は、安全だと感じるために、あらゆる関数や値にこれらのフックを適用して固めてしまいがちです。しかし、その習慣は裏目に出ます。
Reactはキャッシュされた値をメモリに保存する必要があります。レンダリングのたびに、依存配列を反復処理し、Object.is を使用して各アイテムを比較しなければなりません。その比較は低コストですが、無料ではありません。onClick={() => setOpen(true)} のような些細なイベントハンドラーを useCallback でラップすると、一瞬で割り当てられるはずの関数の生成を避けるために、メモリとCPUのコストを支払うことになります。
また、これらのフックはコードにノイズを加えます。useMemo や useCallback でラップされたコードは、可読性も保守性も低下します。すべての依存配列は、いつ牙をむくかわからない潜在的な stale closure の塊なのです。
真の経験則は、地味ですが効果的です。まずはプレーンなコードを書きましょう。問題の証拠がある場合にのみ最適化を行います。React DevTools Profiler を使用して、どのコンポーネントがコストを消費しているか、どのレンダリングが無駄であるかを特定してください。もしレンダリングが数ミリ秒未満で終わるなら、ユーザーは気づきませんし、メモ化しても何も解決していないことになります。
結論
useMemo はコストの高い値のためのものです。useCallback は安定した関数の参照のためのものです。どちらのフックも、それ自体がコンポーネントのレンダリングを速くするわけではありません。これらは不要な下流の処理を防ぐためのものです。まずはフックなしで始め、実際のツールで計測し、プロファイラーがボトルネックを示した場所にのみ正確に追加してください。時折再レンダリングされるクリーンなコードは、あらゆるものをメモ化するオーバーエンジニアリングされたコードよりも、ほぼ常に優れています。
