やあ、今日もコードと格闘ご苦労様。
中級の壁を突破しようともがいている君なら、一度は「なぜこの子コンポーネントが無駄に再レンダリングされるんだ?」とReactのパフォーマンスチューニングの沼にハマったことがあるはずだ。
「よし、`React.memo`で子コンポーネントをメモ化したぞ! これで完璧だ!」
――そう意気込んでブラウザのProfilerを覗いてみたら、親が再描画されるたびに子までしっかり再レンダリングされている。そんな絶望を味わった夜はないかい?
原因の大半は、親から子へ渡している「関数プロップス」にある。
今回は、この泥臭いパフォーマンス問題の特効薬である `useCallback` について、ブラウザの裏側の動きまで含めて徹底的に解剖していこう。現場のリアルな実践知を伝授するから、しっかりついてきてくれ。
—
1. なぜ「関数」を渡すと子コンポーネントが再レンダリングされるのか?
まず、JavaScriptのプリミティブと参照型の違いを思い出してほしい。
Reactのコンポーネントが再レンダリングされるということは、「関数が最初から最後までもう一度実行される」ということだ。
親コンポーネント内で定義された関数は、親が再描画されるたびに「全く同じ中身であっても、メモリ上の別のアドレスに新しく生成される」。
// 親コンポーネントが再描画されるたびに、handleClickは「別物」として新しく生まれ変わる
const handleClick = () => {
console.log(‘Clicked!’);
};
JavaScriptのオブジェクト比較(`===`)は参照アドレスを見る。つまり、親が再描画されるたびに新しい関数インスタンスが生まれ、それをプロップスとして受け取っている子コンポーネント側から見ると、「あれ? プロップスの参照が変わってるぞ! 親が変わったから俺も再描画しなきゃ!」と勘違いしてしまうわけだ。
ここで `React.memo` を使っていても、プロップスとして渡ってきた関数が「別物」と判定されてしまうため、メモ化が完全に意味をなさなくなる。これが、関数プロップスにおける最適化の罠だ。
—
2. `useCallback` の正体と、ブラウザの裏側の処理
そこで登場するのが `useCallback` フックだ。
こいつの仕事は一言で言えば、「依存配列(deps)が変わらない限り、同じ関数の参照をメモリ上にキャッシュし続けること」。
import { useState, useCallback } from ‘react’;
const memoizedCallback = useCallback(() => {
doSomething(a, b);
}, [a, b]); // a と b が変わらない限り、この関数の参照アドレスは維持される
ブラウザの裏側で何が起きているか?
Reactのランタイムは、`useCallback` が呼ばれると、内部のファイバー(Fiber)ノードのメモ化領域に関数インスタンスと依存配列を保存する。
次のレンダリング時、依存配列の要素を前回のものと `Object.is` で比較し、変化がなければ新しく関数を作るコストをケチり、前回保存した関数インスタンスの参照をそのまま返す。
これにより、子コンポーネント側で `React.memo` と組み合わせたときに「プロップスの参照が前回と同じ」と判定され、無駄な再レンダリングを華麗にスキップできるようになるのだ。
—
3. 実務で即座に使える! 綺麗なサンプルコード
言葉だけでは腹落ちしないよな。実際の現場でよくある「検索フィルター付きリスト」を想定したコードを書いた。
このコードをそのまま君のエディタに貼り付けて、何が最適化されているか感じ取ってほしい。
import React, { useState, useCallback, memo } from ‘react’;
// ==========================================
// 1. 子コンポーネント(React.memoでメモ化)
// ==========================================
interface DeleteButtonProps {
id: string;
onDelete: (id: string) => void;
}
// むやみな再レンダリングを防ぐため、memoでラップする
const DeleteButton = memo(({ id, onDelete }: DeleteButtonProps) => {
console.log(`[子描画] DeleteButtonがレンダリングされました: ID = ${id}`);
return (
);
});
DeleteButton.displayName = ‘DeleteButton’;
// ==========================================
// 2. 親コンポーネント
// ==========================================
export const ItemListManager: React.FC = () => {
const [items, setItems] = useState
const [count, setCount] = useState
// 【重要】useCallbackで関数をラップし、itemsが変化しない限り参照を維持する
// もしここが無名アロー関数や普通の関数定義だと、下の「カウントアップボタン」を押しただけでも
// 親の再描画に引きずられてこの関数が新しくなり、上のDeleteButtonがすべて再描画されてしまう。
const handleDelete = useCallback((targetId: string) => {
setItems((prevItems) => prevItems.filter((item) => item !== targetId));
}, []); // 依存配列が空なので、この関数はマウント時に一度作られたら二度と再生成されない
return (
実務向け useCallback サンプル
{/ 親の状態(count)を変更しても、リストと子コンポーネントは再描画されないはず! /}
親のカウンター: {count}
学習項目リスト
-
{items.map((item) => (
-
{item}
{/ 子コンポーネントへ安定した関数(handleDelete)を渡す /}
{/ ダミーのJSX /}
))}
{items.length === 0 &&
アイテムはすべて削除されました。
}
);
};
このコードの肝は、親のカウンター(`count`)をポチポチとインクリメントしても、コンソールに `[子描画] DeleteButtonがレンダリングされました` が流れない点だ。`useCallback` と `React.memo` がガッチリ噛み合い、無駄なDOM差分計算コストを削ぎ落としている証拠だな。
—
4. シニアからのお節介:`useCallback` のアンチパターンと見極め方
ここまで読んだ熱心な君なら、「じゃあ、プロジェクト内のすべての関数に `useCallback` を貼れば最強じゃん!」と思うかもしれない。
絶対にやめてくれ。それは最悪のアンチパターンだ。
実は、`useCallback` を使うこと自体にもコストがある。
1. レンダリングのたびに、依存配列(deps)の中身を比較するJavaScriptの処理コストが発生する。
2. コードの行数が増え、可読性が下がる。
どんな時に `useCallback` を使うべきか?
判断基準は極めてシンプルだ。
1. その関数を渡す先の子コンポーネントが、確実に `React.memo` でメモ化されているか?
- メモ化されていない普通の子コンポーネントに渡す場合、`useCallback` を使っても何の意味もない。親が再描画されればどうせ子は再描画されるからだ。
2. その関数が、別のフック(`useEffect` など)の依存配列に組み込まれているか?
- 無限ループを防ぐために、関数の参照を固定したいケース(`useEffect` のトリガーなど)では `useCallback` が必須になる。
これらに該当しない、単なる子へのイベントハンドラーの受け渡しであれば、まずは `useCallback` なしで書き、パフォーマンスのボトルネック(計測結果)をProfilerで確認してから導入しても遅くはない。
—
まとめ
パフォーマンス最適化は、銀の弾丸(Silver Bullet)を探す旅じゃない。
「なぜブラウザが重くなっているのか」「どこで無駄な計算が走っているのか」をロジカルに突き止め、適切な道具を適切な場所に当てがう、エンジニアとしての職人技だ。
`useCallback` は、そのための強力なドライバーの一つ。
仕組みの本質を理解し、チームのコードベースをワンランク上の品質に引き上げてくれ。期待しているぞ!

コメント