Reactのmemoで、子コンポーネントの余計な再描画を止める方法

​​React​memo,Playwright,TypeScript,useCallback,useMemo

親コンポーネントが描き直されると、その中の子コンポーネントも、props が変わっていなくても一緒に描き直されます。子を memo で包むと、props が前と同じあいだは、この再描画を止められます。ただし、その場で書いたオブジェクトや関数を props に渡すと、memo は効かなくなります。

筆者は検証環境で、書き方を変えた親子のコンポーネントを用意し、親を3回描き直したときに子が何回描き直されるかを数えました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し、Chromium 141 を Playwright 1.56.1 で操作しました。開発用と本番用の両方のビルドで試し、結果は同じでした。StrictMode は使っていません。React Compiler も入れていないので、コンパイラを入れた場合の動きは試していません。

ポイント

memo は、props の1つ1つを前の値と Object.is で比べ、すべて同じなら子を描き直しません。描画のたびに作り直されるオブジェクト・関数・JSX は「前と違う」と判定されるので、useMemo や useCallback で同じものを渡し続けるか、文字列や数値の props に分けます。

描画回数の数え方

子が描き直された回数は、次のフックで数えました。公式リファレンスの useEffect の引数の説明にあるとおり、依存配列を書かない useEffect は、そのコンポーネントが描き直されて画面に反映されるたびに動きます。StrictMode では開発時に初回のエフェクトがもう1回動いて回数がずれるので、検証では StrictMode を使っていません。

ts
import { useEffect } from 'react';

declare global {
  interface Window {
    renderLog: string[];
  }
}

// 依存配列なしの useEffect は、そのコンポーネントが描画されて画面に反映されるたびに動く
export function useRenderLog(name: string) {
  useEffect(() => {
    window.renderLog.push(name);
  });
}

どの親にも「親を描き直す」ボタンを置き、押すと親の state(count)が1増えるようにしました。以下のコードに出てくる Shell は、このボタンと子を並べるだけの入れ物です。ページを開いたあと、Playwright でこのボタンを3回押し、window.renderLog に残った名前を数えています。以下の回数は、どれも初回の描画を含みます。

memo で包むと、props が同じあいだは描き直されない

まず、文字列の props を1つだけ受け取る子を、memo なしと memo ありの2通りで書きました。

tsx
export function Label({ text }: { text: string }) {
  useRenderLog('Label');
  return <p>{text}</p>;
}

export const MemoLabel = memo(function MemoLabel({ text }: { text: string }) {
  useRenderLog('MemoLabel');
  return <p>{text}</p>;
});

親は子ごとに別に用意し、どちらの親も子に text="こんにちは" を渡します。親を3回描き直した結果は次のとおりです。

text
NoMemo(親を3回描き直したあと): {"Label":4,"Parent":4}
MemoPrimitive(親を3回描き直したあと): {"MemoLabel":1,"Parent":4}

memo なしの Label は、親と同じ4回描き直されました。memo で包んだ MemoLabel は初回の1回だけで、親を描き直しても描き直されませんでした。

その場で書いたオブジェクトや関数を渡すと、毎回描き直される

次に、memo で包んだ子に、オブジェクトと関数を渡しました。親の中で、JSX の属性にそのまま書いています。

tsx
<MemoBox style={{ color: 'teal' }} />
<MemoButton onSelect={() => setSelected(selected + 1)} />
text
MemoInlineObject(親を3回描き直したあと): {"MemoBox":4,"Parent":4}
MemoInlineFunction(親を3回描き直したあと): {"MemoButton":4,"Parent":4}

どちらも、memo がないときと同じ4回になりました。{ color: 'teal' } や () => ... は、親が描き直されるたびに新しいオブジェクト・新しい関数として作られます。中身が同じでも別のものなので、Object.is で比べると「違う」になります。公式リファレンスの props の変化を減らす方法の説明にも、Object.is(3, 3) は true でも、Object.is({}, {}) は false だと書かれています。

useMemo と useCallback で、同じものを渡し続ける

オブジェクトは useMemo、関数は useCallback で包むと、依存配列の値が変わらないあいだは、前と同じものが返ります。

tsx
export function MemoStable() {
  const [count, setCount] = useState(0);
  const [, setSelected] = useState(0);
  useRenderLog('Parent');
  const style = useMemo(() => ({ color: 'teal' }), []);
  const handleSelect = useCallback(() => {
    setSelected((s) => s + 1);
  }, []);
  return (
    <Shell onClick={() => setCount(count + 1)}>
      <MemoBox style={style} />
      <MemoButton onSelect={handleSelect} />
    </Shell>
  );
}
text
MemoStable(親を3回描き直したあと): {"MemoBox":1,"MemoButton":1,"Parent":4}

MemoBox も MemoButton も1回だけになりました。

handleSelect の中では、setSelected(selected + 1) ではなく、setSelected((s) => s + 1) と書いています。こう書くと関数の中で selected を読まないので、依存配列を空にできます。公式リファレンスにも、メモ化したコールバックから state を更新するときの書き方として、この形が紹介されています。

反対に、依存配列に親の描画のたびに変わる値が入っていると、useCallback を使っても毎回新しい関数になります。count を読む関数を、依存配列を [count] にして useCallback で包んだ場合は、次の結果になりました。

tsx
const handleSelect = useCallback(() => {
  console.log('選んだときの count', count);
}, [count]);
text
CallbackWithChangingDeps(親を3回描き直したあと): {"MemoButton":4,"Parent":4}

children に JSX をその場で書いて渡すと、memo は効かない

children も props の1つです。memo で包んだ入れ物に、JSX と文字列をそれぞれ children として渡しました。

tsx
<MemoCard>
  <p>中身</p>
</MemoCard>

<MemoCard>中身</MemoCard>
text
MemoJsxChildren(親を3回描き直したあと): {"MemoCard":4,"Parent":4}
MemoStringChildren(親を3回描き直したあと): {"MemoCard":1,"Parent":4}

<p>中身</p> を渡したほうは、4回描き直されました。公式リファレンスの、JSX をメモ化する方法の説明にあるとおり、JSX の要素もオブジェクトで、親が描き直されるたびに新しく作られるからです。文字列の「中身」だけを渡したほうは、1回で止まりました。同じ説明には、JSX を useMemo で包んで同じ要素を渡し続ける書き方も載っていますが、この記事では試していません。

入れ物のほうが自分の state を変える場面では、結果が変わります。その場合、children で受け取った中身は描き直されません。これは以前の記事「Reactのchildrenで、propsのバケツリレーを減らす方法」で確かめました。ここで試したのは、children を作っている親のほうが描き直される場面です。

第2引数の比較関数で、中身を比べる

props を毎回作り直すしかないときは、memo の第2引数に比較関数を渡せます。前の props と新しい props を受け取り、同じとみなすなら true を返します。次の例では、数値の配列を中身で比べました。

tsx
type ChartProps = { values: number[] };

function sameValues(prev: ChartProps, next: ChartProps) {
  return prev.values.length === next.values.length && prev.values.every((v, i) => Object.is(v, next.values[i]));
}

const MemoChart = memo(function MemoChart({ values }: ChartProps) {
  useRenderLog('MemoChart');
  return <p>{values.join(', ')}</p>;
}, sameValues);

親は、描き直すたびに [3, 1, 4].map((v) => v * 10) で新しい配列を作り、比較関数のない MemoChartDefault と、MemoChart の両方に渡します。

text
MemoCustomCompare(親を3回描き直したあと): {"MemoChartDefault":4,"MemoChart":1,"Parent":4}

比較関数のない MemoChartDefault は4回、MemoChart は1回でした。

注意

比較関数で true を返すと、子は前の props のまま動き続けます。公式リファレンスの 比較関数の注意では、関数を含むすべての props を比べるように書かれています。関数を比べずに true を返すと、子が古い state を読んだままの関数を呼び続けるためです。同じ説明では、深さの決まっていないデータを中身までたどって比べると、非常に遅くなりうるとも注意しています。

書き方ごとの結果

ここまでの結果を、1つのグラフにまとめました。

親を3回描き直したときの子の描画回数を、書き方ごとに比べた横棒グラフ。memo なし、その場で書いたオブジェクト、その場で書いた関数、children に JSX の4つは4回。文字列の props、useMemo と useCallback、比較関数の3つは1回
親を3回描き直したときの、子の描画回数(初回を含む)

memo があっても描き直される場面

memo が比べるのは、親から渡された props だけです。次の3つの場面では、memo で包んだ子も描き直されました。

text
MemoWithContext(親を3回描き直したあと): {"MemoCountView":4,"Parent":4}
MemoChangingProp(親を3回描き直したあと): {"MemoLabel":4,"Parent":4}
MemoOwnState(子のボタンを3回押したあと): {"MemoCounter":4,"Parent":1}

1行目は、子が useContext で読んでいる Context の値を、親が変えた場合です。2行目は、`押した回数 ${count}` のように、props の値そのものが毎回変わる場合です。3行目は、子が自分の state を変えた場合で、このときは親は描き直されず、子だけが4回描き直されました。公式リファレンスの props が変わらないときに描き直しを省く使い方の説明にも、memo で包んでいても、自分の state や、使っている Context が変わると描き直されると書かれています。

また、同じリファレンスは、memo は性能を上げるための手段だと位置づけています。「描き直さないことが保証される」ものではないので、描き直されると動きがおかしくなるコードを、memo で隠さないように、とも書かれています。

memo を付けるかどうかの決め方

公式リファレンスの どこにでも memo を付けるべきかの説明によると、memo が役に立つのは、同じ props のまま何度も描き直され、しかも描き直しが重いコンポーネントです。描き直しで目に見える遅れがないなら、memo はなくてかまいません。また、いつも違う props を受け取るコンポーネントでは、memo はまったく役に立たないとしています。

公式リファレンスによると、React Compiler を使えば、memo に当たる最適化をコンパイラが自動で入れるので、有効にしていれば、通常は memo を手で書く必要はありません。コンパイラで useMemo や useCallback がどう変わるかは、以前の記事「React CompilerでuseMemoとuseCallbackは不要になるのか」で試しました。

手で memo を付けるなら、まず、同じ props のまま何度も描き直されている重いコンポーネントに絞ります。そのうえで、渡すオブジェクトは useMemo、関数は useCallback で包みます。state を更新するときは setSelected((s) => s + 1) のように書いて、描画のたびに変わる値を関数の中で読まないようにすると、依存配列が変わらず、同じ関数を渡し続けられます。親が描き直されるたびにその場で書いた JSX を children に渡すと、memo は効きません。その場合は、文字列のような値の props に分けられないかを考えます。

​Posted by 86