4.6 パフォーマンス — memo, useMemo, useCallback, React Compiler
React の UI は「props / state が変わったコンポーネントが再レンダーされる」のが基本です。ほとんどのアプリではこれで十分速くて、パフォーマンスチューニングのことを考える必要はありません。ただ、規模が大きくなると「毎入力で巨大なリストが再レンダーされて入力がカクつく」といった問題に出会います。この章では、その対処に使う道具と、その使い分けを扱います。
最重要: 早すぎる最適化をしない
最初に伝えておきたい原則があります。
パフォーマンス最適化は、計測してから行う。 コードを読みにくくする前に、「そもそも遅いのか」を確認する。
公式ドキュメントの useMemo や memo のページも、「これはパフォーマンス最適化です。動かない問題を解決するために使うものではありません」と繰り返し書いています。ここはぜひ覚えて帰ってください。
計測方法は主に 2 つあります。
- React DevTools の Profiler タブ: 「どのコンポーネントが、何 ms で、何回レンダーされたか」を可視化できます。最初の探索はこれで十分です。
- ブラウザの Performance パネル: CPU 全体のプロファイル。React 外の処理(JSON パース、重い計算)も見えます。
「体感で遅い」「遅くなりそう」で手を打つのではなく、DevTools で数字を見てから 最適化に着手する、というのが鉄則です。
図 4.6.1 最適化の意思決定フロー
なぜ再レンダーが起きるか
コンポーネントが再レンダーされる条件は、次のいずれかです。
- 自分の state が変わった。
- 自分が受け取る props が変わった(親が再レンダーしたとき、参照が変わったため)。
- 自分が読んでいる Context の値が変わった。
- 親が再レンダーした(React は親が再レンダーしたら子も再レンダーするのが基本動作)。
4 が意外と多い ことに注意してください。親の state が 1 つ変わっただけで、親配下のすべての子が再レンダーの対象になります。多くの場合はこれでも問題ないのですが、子が重い計算やレンダーをしているときに効いてきます。
図 4.6.2 再レンダーの波及
React.memo — 子の再レンダーを止める
memo(Component) でコンポーネントをラップすると、props が前回と shallow equal なら再レンダーをスキップ します。
// react.dev/reference/react/memo より翻案
import { memo } from "react";
const Greeting = memo(function Greeting({ name }: { name: string }) {
return <h1>Hello, {name}!</h1>;
});ただし、気をつける点があります。
props にオブジェクト / 配列 / 関数を渡していると、毎レンダーで参照が変わる ため、
memoは意味を持ちません。tsx// onClick は毎レンダーで新しい関数。memo が効かない <MemoChild onClick={() => foo()} />このような場合は、呼び出し側で
useCallback/useMemoによって参照を安定させる必要があります。context や state の変化 は
memoを貫通します。自分が使っている context が更新されれば、memoでも再レンダーされます。memoは保証ではありません。React は都合により memoization を破棄する可能性があると公式に明記されています(特に開発モードや Suspense 絡みの挙動)。
触って確かめる — memo が効くとき・効かないとき
下のデモでは、「親を再レンダー」ボタンを押したとき、3 種類の子がそれぞれ何回再レンダーされるかをカウントします。真ん中の「memo あり(安定した props)」だけカウントが進まないはずです。右端の「memo あり(新しい参照の props)」は、memo を使っていても props が毎回新しいオブジェクトなので、memo が無効化されて普通に再レンダーされてしまいます。
<M name="yuichkun" />
3 つの子を同じ親の下に配置し、「親を再レンダー」ボタンを押したときのカウントを比較するシミュレーションです。 memo だけ では不十分で、props の参照も安定させて初めて意味を持つことが伝わればこのデモの役目は果たしたことになります。
useMemo — 計算結果をキャッシュする
重い計算の結果を、依存配列が変わらない限り再利用するためのフックです。
import { useMemo } from "react";
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab]
);効く場面:
filterTodosが 1 ms 以上かかる重い処理で、todos/tabがそう頻繁には変わらない。- 計算結果の 参照を安定させたい とき。例えば
memo化した子に渡すオブジェクトや、useEffectの依存に入れるオブジェクトなど。
意味がない、あるいは逆に遅くなる場面:
x + yのような軽い計算。キャッシュのコストの方が高くなります。- 依存が毎回変わる(例: 毎レンダーで新しい配列を作って依存に入れる)と、メモ化は成立せず overhead だけ増えます。
useCallback — 関数の参照を安定させる
useMemo で関数をラップするのと等価なショートカットです。
import { useCallback } from "react";
const handleSubmit = useCallback(
(data: FormData) => {
post(`/api/${id}`, data);
},
[id]
);効く場面 は限定的です。次のどれかが成立するときだけ価値があります。
- この関数を
memo化した子コンポーネントに props で渡す。 - この関数が
useEffect/useMemo/useCallbackの依存配列に入る。 - 関数を返すカスタムフックの中で、外側に安定した参照を返したい。
上記に当てはまらない場合、useCallback は単なるノイズになります。手書きで散らすほど、コードの見通しが悪くなっていきます。
3 つの関係を整理
memo だけ でも、useCallback だけ でも、通常は効果が出ません。「memo した子」に「useCallback / useMemo で安定させた props」を渡す という 3 点セットで初めて意味を持ちます。ここは最初に混乱しやすいところなので、上のデモで実感してもらえるとよさそうです。
React Compiler — 自動で memoization を入れる
手書きで memo / useMemo / useCallback を散らす運用は、読みにくく、漏れやすい問題があります。React チームは React Compiler(Babel プラグイン babel-plugin-react-compiler)を提供していて、これを有効にすると コンパイラが自動的に必要な memoization を挿入 してくれます。
- React 17 / 18 / 19 で動作(React 19 との組み合わせが最も素直)。
- 開発時には ESLint プラグイン(
eslint-plugin-react-compiler相当)が、Rules of React 違反を教えてくれます。 - インストールは dev 依存として
babel-plugin-react-compiler@latestを追加し、プラグインリストの 最初 に置きます。 - Vite / Next.js / Webpack / Expo 等、それぞれの Babel 設定に組み込みます。
公式は「手書きの useMemo / useCallback / memo を徐々に不要にできる」位置づけで紹介しています。新規プロジェクトでは有効化しておくと、パフォーマンス最適化のことをほとんど気にしなくて済むようになります。
React Compiler は stable(安定版)としてリリースされており、Meta など本番環境での実績もあります。react.dev は "React Compiler is now stable and has been tested extensively in production." と明記しています。採用時は、コードベースが Rules of React に沿っているかを ESLint プラグインで確認したうえで、段階的に有効化する流れが推奨されています。
useTransition — 緊急度の低い更新を分離する
useTransition は 「この更新は後回しにしていい」 と React に伝えるための道具です。
import { useState, useTransition } from "react";
function SearchApp() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value); // 緊急: 入力欄の表示を即更新
startTransition(() => {
setResults(heavySearch(e.target.value)); // 非緊急: 重い検索結果
});
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <p>検索中...</p>}
<ul>{results.map((r) => <li key={r}>{r}</li>)}</ul>
</>
);
}startTransitionの中の更新は 中断可能 です。ユーザーが次の入力をしたら、古い Transition はキャンセルされます。isPendingで「裏で計算中」を示せるので、UI はフリーズせずに済みます。
useDeferredValue — 値の更新を遅延させる
似た目的のフックですが、こちらは 値そのものを遅延 させます。
import { useDeferredValue, useState } from "react";
function SearchApp() {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query, ""); // React 19 で初期値を指定可能
const isStale = query !== deferredQuery;
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<SlowResults query={deferredQuery} />
</div>
</>
);
}- 入力欄(
query)は即更新、重い<SlowResults>はdeferredQueryを見るので一拍遅れてついてきます。 - React 19 で第 2 引数
initialValueが追加され、初回レンダー時にも遅延表示できる ようになりました。
useTransition との違いは、useTransition が 更新の「きっかけ」 を指定するもの、useDeferredValue が 値の「遅延ラベル」 を付けるもの、と捉えると整理しやすいです。
現場の意思決定フロー
「遅い」と感じたときの手順を示します。
- React DevTools Profiler で record。どのコンポーネントが何 ms かかったか確認。
- 最も時間のかかっているコンポーネントを特定。
- 原因が 計算の重さ か レンダー回数の多さ かを切り分ける。
- 計算の重さなら
useMemoを検討。レンダー回数なら props の参照安定化(useCallback/useMemo)とmemoを検討。 - そもそも React Compiler を導入していないなら、先にそれを検討。
- 大きなリストで表示数が多すぎるなら、仮想スクロール(react-window / TanStack Virtual など)を検討。
- 入力と結果がセットなら
useTransition/useDeferredValueを検討。
手を打つ前に 計測 を挟むのが唯一のルールです。これだけは頭に刻んでおいてもらえるとよさそうです。