「重いから React.memo を貼っておいた」というコードは、かなりの割合で効いていません。しかも効いていないことに気づきにくいのが厄介なところです。
この記事では、実際に React 18 で計測用のページを作り、再レンダリングの回数と所要時間を数えました。以下の数字はすべて実行結果です。
計測の内容
親コンポーネントが state を持ち、その下に少し重い子コンポーネントを500個並べます。親の state を10回更新して、子が何回レンダリングされたかを数えます。
function Child({ item, onSelect, tag }) {
counts[tag]++; // レンダリング回数を数える
let s = 0;
for (let i = 0; i < 3000; i++) s += Math.sqrt(i); // 重めの描画を模擬
return <div>{item.name}</div>;
}
const MemoChild = memo(Child);
比べるのは3パターンです。子に渡す item は3つとも同じで、useMemo で参照を固定してあります。違うのは memo の有無と、コールバックの作り方だけです。
// A: memo なし
<Child item={it} onSelect={() => {}} />
// B: memo あり。ただし onSelect は毎レンダーで新しい関数
<MemoChild item={it} onSelect={() => {}} />
// C: memo あり + useCallback で参照を固定
const onSelect = useCallback(() => {}, []);
<MemoChild item={it} onSelect={onSelect} />

| 子の再レンダリング | 所要時間 | |
|---|---|---|
| A. memo なし | 5,000回 | 38.6ms |
| B. memo あり(props が毎回新しい) | 5,000回 | 34.1ms |
| C. memo + useCallback | 0回 | 2.9ms |
A と B がほぼ同じです。memo を貼ったのに、再レンダリングは1回も減っていません。むしろ props の比較処理が増えるぶん、無駄な仕事が乗っています。
なぜ B は効かないのか
React.memo がやっているのは、前回の props と今回の props を浅く比較して、同じならレンダリングを飛ばすことだけです。この「同じ」は Object.is による参照の比較です。
// 中身は同じでも、毎回新しく作られたものは「別物」
(() => {}) === (() => {}) // false
{ a: 1 } === { a: 1 } // false
[1, 2] === [1, 2] // false
親がレンダリングされるたびに () => {} は新しい関数として作られます。props が1つでも違えば、memo は必ずレンダリングを実行します。他の props がどれだけ安定していても関係ありません。
つまり React.memo は、単体では機能しません。渡す側で参照を固定して、初めて成立します。
- 関数 →
useCallback - オブジェクト・配列 →
useMemo - 計算結果 →
useMemo
逆に言えば、memo を貼るときは、渡している props を全部確認する必要があります。1つでも毎回作り直されるものが混ざっていれば、その memo は無意味です。

よくある「効かない」パターン
実際のコードで参照が壊れる書き方を挙げます。どれもJSXの見た目は自然です。
// インラインのオブジェクト
<MemoChild style={{ margin: 8 }} />
// インラインの配列
<MemoChild items={[a, b]} />
// その場で作る関数
<MemoChild onClick={() => handle(id)} />
// 毎回新しい配列を返す処理
<MemoChild list={data.filter(d => d.active)} />
// children にJSXを渡す(要素も毎回新しく作られる)
<MemoChild><Icon /></MemoChild>
特に見落としやすいのが最後の2つです。filter や map は中身が同じでも毎回新しい配列を返します。子に渡す前に useMemo で包む必要があります。
onClick={() => handle(id)} のように引数を渡したい場合は、コールバックの側で受け取る形にすると useCallback で固定できます。
// 親側: 参照が固定される
const onSelect = useCallback((id) => handle(id), [handle]);
// 子側: 自分のIDを渡して呼ぶ
<button onClick={() => onSelect(item.id)}>選ぶ</button>
そもそも memo が要らないことも多い
ここまで memo の話をしてきましたが、実務では「memo を貼る」より先にやるべきことがあります。
state を持つ位置を下げる
今回の計測で子が500個も再レンダリングされたのは、カウンターの state を親が持っていたからです。その state を使うのはカウンターの表示部分だけでした。
// Before: 親が state を持つ → 子500個が巻き添えになる
function Parent() {
const [count, setCount] = useState(0);
return <><p>{count}</p><List /></>;
}
// After: state を必要な場所に閉じ込める → List は無関係になる
function Counter() {
const [count, setCount] = useState(0);
return <p>{count}</p>;
}
function Parent() {
return <><Counter /><List /></>;
}
これなら memo も useCallback も要りません。再レンダリングの範囲は、state を置いた場所から下だけです。範囲を狭くするほうが、比較処理で潰すより確実です。
children で挟む
state を下げられない場合でも、重い部分を children として受け取ると巻き添えを避けられます。
function Parent({ children }) {
const [count, setCount] = useState(0);
return <><p>{count}</p>{children}</>;
}
// 使う側。<List /> は Parent の外で作られるので、
// Parent が再レンダリングされても作り直されない
<Parent><List /></Parent>
children として渡された要素は、親の外側で作られています。親が再レンダリングされても、その要素オブジェクト自体は同じものが使い回されるため、再レンダリングされません。memo を1つも使わずに済みます。
測ってから貼る
今回いちばん伝えたいのはここです。B のパターンは、コードを見ただけでは A と区別がつきません。「memo を貼った」という事実だけが残り、効果があったかどうかは誰も確認していない、という状態になりがちです。
確認する方法はいくつかあります。
- React DevTools の Profiler
どのコンポーネントが何回レンダリングされたか、なぜレンダリングされたかまで表示できます。設定で「Record why each component rendered」を有効にします - DevTools の Highlight updates
更新された部分が画面上で光ります。関係ない場所が光っていたら、そこが巻き添えです - 今回のようにカウンターを仕込む
コンポーネントの先頭で数えるだけなので、確認したい箇所に一時的に入れるのが手軽です
そして、そもそも遅くないものを最適化しないことも大事です。今回は500個の子に重い処理を入れて、ようやく38msでした。子が20個の一覧なら、memo の有無は体感に出ません。useCallback や useMemo 自体もタダではないので、効果を測らずに全部に貼ると、読みにくくなるだけで速くはなりません。
まとめ
React.memoは単体では効かない
渡す props の参照を固定して初めて機能する- props が1つでも毎回新しければ、比較は必ず不一致になる
今回は再レンダリングが1回も減らなかった - インラインのオブジェクト・配列・関数、
filterの結果は毎回別物
JSXの見た目では気づけない - まず state を持つ位置を下げる
範囲が狭ければ memo は要らない childrenで挟むと巻き添えを防げる
親の外で作られた要素は作り直されない- 貼ったら測る
効いていない memo は、コストだけ増やしている
次に読む記事



