はじめに
Reactのレンダリング最適化で、React.memoの代わりにReact.useMemoを使ったらダメな理由を厳密に理解できていなかったのでこの記事をかきました。
例えば、こんなコードを見かけたことはないでしょうか?
export const Child: React.FC<Props> = ({ item }) => {
return React.useMemo(() => {
const limit = getLimit(item); // ちょっと重い処理
return (
<Grid item={true} xs={item.width}>
<TextField label={item.displayName} disabled={item.disabled} limit={limit} />
</Grid>
);
}, []);
};
JSX ごと useMemo で包み、依存配列は []。「これで再レンダリングを回避できている」と思えるコードです。
実際、動かしてみると画面はチラつかないし、重い処理も走っていないように見えます。しかしこれは React.memo の代わりにはなりません。この記事では「JSX の中に重い処理があり、その再実行を避けたい」という前提で、React.memo の代わりに useMemo(..., []) を使うことのデメリットを整理します。
問題
前提の整理
まず、両者が何をするものかを確認します。判定が行われる位置が根本的に違います。
| 判定の位置 | スキップされる範囲 | |
|---|---|---|
React.memo |
コンポーネント関数を呼ぶ前 | 関数本体まるごと |
useMemo |
コンポーネント関数の中 | そのコールバックの戻り値だけ |
useMemo はフックなので、コンポーネント関数が呼ばれてフックの行に到達しなければ評価されません。つまり「レンダリングが起きたこと」が前提の仕組みです。
その上で、以下のデメリットが出てきます。
1. props の変化が画面に反映されない(バグになる)
これが決定的な差です。
deps: [] は「props が変わっても再計算しない」ので、item が更新されても表示は初回のままになります。React.memo は「props が変わったら再実行する」ので更新されます。
差が見える最小の例を作ります。
const Parent = () => {
const [name, setName] = useState('Tanaka');
const [count, setCount] = useState(0);
return (
<>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button onClick={() => setCount(count + 1)}>{count}</button>
<Child name={name} />
</>
);
};
name は Child に渡る値、count は渡らない値です。期待する挙動は次の 2 つです。
-
countを押したとき:Childは再実行されない(回避したい) -
nameを編集したとき:Childの表示が更新される(正しさ)
パターンA:useMemo(..., [])
const Child = ({ name }: { name: string }) => {
console.log('Child 実行');
return useMemo(() => <div>{name}</div>, []);
};
| 操作 | ログ | 画面 |
|---|---|---|
count を押す |
出る | 変わらない |
name を編集 |
出る | 変わらない(バグ) |
パターンB:React.memo
const Child = React.memo(({ name }: { name: string }) => {
console.log('Child 実行');
return <div>{name}</div>;
});
| 操作 | ログ | 画面 |
|---|---|---|
count を押す |
出ない | 変わらない |
name を編集 |
出る | 更新される |
つまり deps: [] は再実行の回避ではなく表示の凍結です。凍結が要件でないなら、これはバグを埋め込んでいることになります。冒頭のコードで言えば、item.disabled が切り替わってもフィールドが有効化されません。
では deps を正しく書けばいいのか
deps: [item] とすれば、この問題は解消します。しかしその途端に別の問題が出ます。親が毎回新しいオブジェクトを作っている場合、参照が変わるので毎回再計算されるのです。
// 親側。item を毎回作っていると deps 比較は常に外れる
{items.map((item) => <Child key={item.id} item={{ ...item, disabled }} />)}
React.memo も同じ弱点を持ちますが、違いは気づけるかどうかです。React.memo なら原因が props に一元化され、React DevTools の Profiler も「props が変わったから再レンダーした」という理由を表示してくれます。useMemo は子の内部に埋まっているため、効いていないことに気づく手段がありません。
deps: [] と正しい deps のどちらを選んでも React.memo より劣る、というのがここでの結論です。
2. 凍結が目的だとしても、保証されない
「いや、凍結したいんだ」というケースもあるでしょう。それでも useMemo は不適切です。
useMemo はキャッシュを破棄することが許されているためです。React 公式ドキュメントも、useMemo はパフォーマンス最適化であり、それがないと動かないコードを書くべきではないとしています。破棄されると凍結が解けて現在の props が表示される可能性があり、しかも再現困難なバグになります。
凍結を要件とするなら、state として持つのが正解です。
const Child = ({ name }: { name: string }) => {
const [initialName] = useState(name); // 初回の値を保持
return <div>{initialName}</div>;
};
useState は破棄されないので、凍結が保証されます。
3. 関数の実行自体は止まらない
deps: [] でも Child 関数は毎回呼ばれ、他のフックも毎回評価されます。パターンA でログが毎回出ていたのがその証拠です。重い処理が useMemo の外にもあれば、それらは全部走ります。React.memo は関数ごと弾きます。
なお、ログをコールバックの中に置くとこの事実が見えなくなるので注意してください。
// これでは「コールバックが実行されたか」しか測れない
return useMemo(() => {
console.log('Child 実行'); // 初回しか出ない
return <div>{name}</div>;
}, []);
Child が呼ばれ、useMemo の行に到達し、deps を比較してキャッシュを返す——この一連の処理は毎回走っています。確認するならログは return の前に置きます。
ただし正直に言うと、ここで得られる差は小さいです。関数コンポーネント 1 回の呼び出しはマイクロ秒オーダーなので、数十個のリストでは体感差はまず出ません。パフォーマンスだけを比較するなら両者はほぼ同じで、この記事のデメリットは主に正しさと保守性の話です。
4. lint と衝突する
react-hooks/exhaustive-deps(フックの依存配列の漏れを検出する ESLint ルール)が警告を出します。
意図的な凍結なら毎回 disable コメントを書くことになりますが、そのコメントが「本当に意図的」なのか「後から入った書き忘れ」なのか、時間が経つと判別できなくなります。
5. 意図が判別できない
読み手は useMemo(..., []) を見て、以下のどれなのかを区別できません。
- 重い処理をスキップしたい
- レンダリング回避を狙っている
- 凍結が意図
- deps を書き忘れている
レビューのたびにこの確認が発生します。凍結が意図なら useState という表明手段がある、というのが 2 の話でした。
6. 契約が呼び出し側に見えない
親は props を渡しているのに無視されます。React.memo(Child) は「props が同じ間はスキップされる」という性質を定義そのものに持つので、利用者が挙動を予測でき、「親側で参照を安定させるべきだ」と気づけます。useMemo は実装の内側に隠れるため、それができません。
解決方法
目的ごとに手段を分けます。
| 目的 | 手段 |
|---|---|
| 子の関数実行そのものを省く |
React.memo + 親側で props の参照安定化 |
| 実行される関数内の重い計算を省く | useMemo |
| 初回の値を保持したい(凍結) | useState(初期値) |
| 同一参照の JSX を下流に渡す |
useMemo(レンダー回避が目的ではない) |
重い処理があること自体は useMemo を使う理由になりますが、JSX ごと包む理由にはなりません。計算だけをメモ化し、JSX は普通に返すのが素直な形です。
// Before
export const Child: React.FC<Props> = ({ item }) => {
return React.useMemo(() => {
const limit = getLimit(item);
return (
<Grid item={true} xs={item.width}>
<TextField label={item.displayName} disabled={item.disabled} limit={limit} />
</Grid>
);
}, []);
};
// After
export const Child: React.FC<Props> = React.memo(({ item }) => {
const limit = useMemo(() => getLimit(item), [item]);
return (
<Grid item={true} xs={item.width}>
<TextField label={item.displayName} disabled={item.disabled} limit={limit} />
</Grid>
);
});
計算とレンダリングの関心が分離され、limit を別の場所(イベントハンドラや親への通知)で使いたくなったときもそのまま渡せます。単体テストも書きやすくなります。
そもそも「重い処理」が本当に重いのかを先に測ることも大切です。JSX オブジェクトの生成は { type, props, key } を作るだけの軽い処理なので、そこをメモ化しても得はありません。React DevTools の Profiler で計測してから判断しましょう。
おわりに
useMemo(..., []) は動いているように見えるだけに、レビューで見落とされやすいコードです。「見た目上は再レンダリングされていない」という観察は正しくても、それは props が変化しないテストケースだったから差が現れていないだけ、というケースがよくあります。
判定が関数の外側にあるのか内側にあるのか。この一点を押さえておけば、React.memo と useMemo の使い分けで迷うことは減るはずです。
参考
JISOUのメンバー募集中!
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてください!
▼▼▼
https://projisou.jp
