React.memoを貼っても再レンダリングは減らなかった|500個で実測

React.memoを貼っても再レンダリングは減らなかった|500個で実測

「重いから 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} />
memoなし5000回38.6ms、memoありでpropsが不安定でも5000回34.1ms、memo+useCallbackで0回2.9msという実測結果のグラフ
A と B の差がほとんどない。memo は貼っただけでは効いていない
子の再レンダリング所要時間
A. memo なし5,000回38.6ms
B. memo あり(props が毎回新しい)5,000回34.1ms
C. memo + useCallback0回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 は無意味です。

舞台から見た赤いビロードの客席。何百もの同じ席が急な角度で奥へ続く写真
1席だけ変えたいのに、全部を数え直している

よくある「効かない」パターン

実際のコードで参照が壊れる書き方を挙げます。どれも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 は、コストだけ増やしている

次に読む記事

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

わどこんのアバター わどこん

実務12年のバックエンド・インフラエンジニア。バックエンド開発からクラウド・インフラの設計・構築・運用まで担当しています。主要言語は Java・Kotlin・PHP・Python。運用の現場で拾った知見を、再現できる手順に落として残すのがこのブログのテーマです。

目次