なぜReactはデフォルトでコンポーネントをメモ化しないのか?

ReactにはReact.memouseMemoといったメモ化の仕組みがありますが、フレームワークの既定挙動は「親が再レンダーすれば子も実行される」というシンプルなモデルです。では、なぜ最初から自動でメモ化して高速化しないのでしょうか。本稿では、その設計思想と実務上の判断基準を、コード例と共に解説します。

用語の整理

レンダーはコンポーネント関数を実行して仮想要素ツリーを計算する工程、コミットはその差分を実DOMに適用する工程です。一般にコミットのほうが高コストになりがちです。メモ化は同じ入力に対して前回の結果を再利用し、不要な再計算(レンダー)を避ける最適化です。React.memo はpropsを浅く比較し、同一なら子の再レンダーをスキップします。ただしstateの更新やcontextの変更、フック内部の更新は対象外です。

なぜデフォルトでメモ化しないのか

第一に、正しさと予測可能性を優先しているからです。自動メモ化は更新の伝播を不透明にし、UIが期待どおりに更新されない状況を生みやすくなります。副作用(useEffect)やレイアウト同期、Suspense、トランジションなどが絡むと、どのタイミングで何が再実行されるべきかを開発者が見通しにくくなります。既定の「親が再レンダーすれば子も実行される」というモデルは学習しやすく、デバッグも容易です。

第二に、メモ化そのものにオーバーヘッドがあるからです。毎回の浅い比較と直前propsの保持が必要で、軽いコンポーネントまで一律に比較コストを払うとむしろ遅くなることがあります。レンダーが少ない場面ほど、比較コストが相対的に目立ちます。「memo を付ければ常に速い」というわけではありません。

第三に、自動適用の判断がランタイムでは難しいからです。どの子が重いのか、どのpropsが参照的に安定しているのかは、実行時に安全に推論しづらい問題です。オブジェクトや関数をインライン生成する一般的な書き方では参照が毎回変わり、浅い比較が効きません。深い比較はさらに高コストで本末転倒です。

さらに、React18以降の並行レンダリング(中断・再開・優先度制御)との整合も重要です。広範囲に自動メモ化されると、どこで再計算・中断・再開されるかが複雑化し、スケジューラの自由度や体感の滑らかさに悪影響を及ぼし得ます。 DXの観点でも、既定でメモ化されていると参照の安定化のためにuseMemouseCallback を乱用しがちで、依存配列や参照の一貫性に意識を奪われ、可読性・保守性・バグ耐性が低下します。

よくある誤解

再レンダーはDOM更新と同義ではありません。レンダーは計算であり、差分がなければコミット(DOM 変更)はほぼ発生しません。また、React.memo は万能ではありません。propsが同一のときだけスキップされます。stateの変化や contextの更新、開発時Strict Modeによる二重実行(検出用の挙動)は止められません。

いつ React.memo を使うべきか

原則は計測してからです。React DevTools の Profilerでどのコンポーネントが何回・どれだけ時間を使っているかを把握し、ボトルネックが子のレンダーにあると分かったときに適用します。同種の子を大量に並べるリストで各子のレンダーが重い、親が高頻度に再レンダーするが子は大抵不変といった条件がそろうと効果が出やすくなります。propsが参照的に安定していて浅い比較で十分、という設計であることも重要です。

効果を最大化するための設計

子に渡すpropsの参照を安定化しましょう。オブジェクト・配列・関数を毎回新規生成せず、必要な場面に限って useMemouseCallback を使いましょう。よく変わる部分を小さく分割し、重い静的部分は別コンポーネントに切り出してメモ化すると、レンダーの波及を抑えられます。渡す情報をIDやbooleanなどのプリミティブへ落とし込むのも浅い比較を効かせる助けになります。ただし過剰最適化は複雑性やバグの温床になり得るため、常に計測で裏取りをしましょう。

コード例

まずは素直な実装です。多くのケースではこれで十分です。

jsx
import React, { useState } from "react";

function UserItem({ user, onSelect }) {
  console.log("render", user.id);
  return (
    <li onClick={() => onSelect(user.id)}>
      {user.name} ({user.score})
    </li>
  );
}

export function UserList({ users }) {
  const [filter, setFilter] = useState("");

  const filtered = users.filter((u) => u.name.includes(filter));

  return (
    <>
      <input
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
        placeholder="filter"
      />
      <ul>
        {filtered.map((u) => (
          <UserItem
            key={u.id}
            user={u}
            onSelect={(id) => {
              /* do something */
            }}
          />
        ))}
      </ul>
    </>
  );
}

子のレンダーが重いと分かった場合に、参照を安定化してから子をmemo化します。

jsx
import React, { memo, useCallback, useState } from "react";

const UserItem = memo(function UserItem({ user, onSelect }) {
  // ここで重い計算やレイアウト計測がある想定
  console.log("render", user.id);
  return (
    <li onClick={() => onSelect(user.id)}>
      {user.name} ({user.score})
    </li>
  );
});

export function UserList({ users }) {
  const [filter, setFilter] = useState("");

  const filtered = users.filter((u) => u.name.includes(filter));

  const handleSelect = useCallback((id) => {
    /* do something */
  }, []);

  return (
    <>
      <input
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
        placeholder="filter"
      />
      <ul>
        {filtered.map((u) => (
          <UserItem key={u.id} user={u} onSelect={handleSelect} />
        ))}
      </ul>
    </>
  );
}

メモ化の効果を打ち消すアンチパターンも示しておきます。毎回新しい参照を 渡すと浅い比較が常に不一致になります。

jsx
<UserItem
  user={{ ...user }} // 毎回新しいオブジェクト
  onSelect={(id) => {
    /* inline */
  }} // 毎回新しい関数
/>

コンテキストと並行レンダリング

React.memo はcontextの更新をブロックしません。子が useContext で値を読んでいるなら、contextの変更時に再レンダーされます。並行レンダリングでは、Reactがレンダーの中断・再開・優先順位付けを行います。ランタイムで広域に自動メモ化してしまうと、どこで再計算が必要かという判断をスケジューラが柔軟に下せなくなり、最適な応答性を得にくくなります。既定挙動をシンプルに保つのは、この自由度を守る狙いもあります。

まとめ

React が既定でコンポーネントをメモ化しないのは、正しさと予測可能性を守りつつ、比較・キャッシュ維持のオーバーヘッドや並行レンダリングへの悪影響を避けるためです。メモ化は魔法ではなく、条件がそろったときに効く局所最適化です。まずは素直に書き、Profilerで測り、重い箇所だけ慎重に適用し、再計測で確かめる。この基本サイクルが、堅実で効果的なパフォーマンスチューニングの道筋です。

101

おすすめ記事