React CompilerでuseMemoとuseCallbackは不要になるのか
React Compilerを入れると、新しく書くコードでは useMemo と useCallback をほとんど書かなくてよくなります。ただし「完全に不要」ではありません。コンパイラが処理をスキップしたコンポーネントや、エフェクトの依存値を細かく制御したい場面では、今も手で書く意味が残ります。
筆者は検証環境で、TypeScript(.tsx)で書いた同じコンポーネントをコンパイラなし・ありの2通りで変換し、子の描画回数とエフェクトの実行回数を数えました。その結果を、公式ドキュメントの方針と合わせて見ていきます。
検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、babel-plugin-react-compiler 1.0.0、@babel/core 8.0.7、@babel/preset-typescript 8.0.7、typescript 7.0.2、@types/react 19.3.0、jsdom 29.1.1 です。コード例は .tsx で書きました。tsc で型チェックしたあと、Babelで変換し、jsdomの上で描画しています。ViteやNext.jsのビルドでは試していません。画像は、同じ変換結果を esbuild で1つのファイルにまとめ、Playwright 1.56.1 の Chromium で動かして撮りました。
公式の答えは「基本は任せる、必要なところだけ書く」
公式ドキュメントの React Compiler 紹介ページでは、useMemo・useCallback・React.memo の扱い について書かれています。ポイントは次のとおりです。
- コンパイラはコードを解析して、自動でメモ化する。そのメモ化は多くの場合、手で書くものと同じか、それ以上にきめ細かい。
useMemoとuseCallbackは、どの値をメモ化するかをコンパイラに任せず自分で決めたいときの手段として、コンパイラと一緒に使い続けられる。よくある例は、メモ化した値をエフェクトの依存値に使う場合。- 新しいコードでは、メモ化をコンパイラに任せ、必要なところだけ
useMemo/useCallbackを使う。 - 既存のコードでは、今あるメモ化をそのまま残すか、外す前にしっかりテストする(外すとコンパイル結果が変わることがあるため)。
同じ方針は、React Compiler v1.0 の発表記事 にも書かれています。
useMemoなしの親で、子の再描画とエフェクトが止まるか
まず、useMemo も useCallback も書いていない親コンポーネントを用意しました。親はボタンを押すたびに自分の state を更新します。子は memo で包んであり、親から配列と関数を受け取ります。
export function Parent({ data }: { data: number[] }) {
const [count, setCount] = useState(0);
const items = data.filter((x) => x % 2 === 0); // 毎回新しい配列
const handleSelect = (i: number) => console.log('select', i); // 毎回新しい関数
const options = { size: items.length }; // 毎回新しいオブジェクト
useEffect(() => { log.effectRuns++; }, [options]);
return (
<div>
<button id="inc" onClick={() => setCount(count + 1)}>{count}</button>
<Child items={items} onSelect={handleSelect} />
</div>
);
}
コンパイラがなければ、配列・関数・オブジェクトは描画のたびに作り直されます。そのため、memo があっても子は毎回描画され、エフェクトも毎回動くはずです。初回の描画のあと、ボタンを3回押した結果は次のとおりです。
$ node run.mjs ./out/app.plain.mjs
./out/app.plain.mjs: button=3 childRenders=4 effectRuns=4
$ node run.mjs ./out/app.compiled.mjs
./out/app.compiled.mjs: button=3 childRenders=1 effectRuns=1
コンパイラなし(plain)では、子の描画もエフェクトの実行も4回でした。コンパイラあり(compiled)では、どちらも初回の1回だけです。useMemo も useCallback も書いていないのに、手で書いたときと同じ効果が出ています。
TypeScriptで書いた場合も、Babelの設定は、コンパイラのプラグインと @babel/preset-react の組み合わせに、@babel/preset-typescript を足すだけで済みました。型注釈は変換の途中で取り除かれます。同じコンポーネントをJavaScript(.jsx)で書いた版でも試しましたが、この記事のどの計測でも回数は同じでした。
同じ2つの変換結果を、ブラウザでも上下に並べて動かしました。esbuildで1つのファイルにまとめ、Playwright 1.56.1 の Chromium で、上下のボタンを交互に3回ずつ押しています。次のGIFアニメーションは、その画面を1コマずつ撮り、0.08秒間隔でつないだものです。
画面上部の帯には、何回目のクリックかが出ます。押した直後に変わった数字は、その欄がピンクの地になります。画面のいちばん下には、どちらも元のコードが同じ src/app.tsx であることを書き添えました。上のコンパイラなしでは、押すたびに子の描画回数とエフェクトの実行回数が増えます。下のコンパイラありでは、どちらも1のままです。

なお、検証で使った子コンポーネントは、描画回数を数えるためにモジュールの変数を書き換えています。これはReactのルール違反なので、子のほうはコンパイラにスキップされていました(検証ログには line 8 のエラーとして残っています)。ただ、子は memo で包んであるため、この比較には影響しません。
コンパイラありで回数が増えなかった理由は、変換後のコードを見ると分かります。コンパイラは値ごとにキャッシュの置き場所($[0] など)を用意し、元になる値が変わったときだけ作り直します。
let t1;
if ($[0] !== data) {
t1 = data.filter(_temp);
$[0] = data;
$[1] = t1;
} else {
t1 = $[1];
}
const items = t1;
const handleSelect = _temp2;
handleSelect は props や state など、コンポーネント内の値を使っていないため、コンポーネントの外に出され、_temp2 という関数になりました。子には毎回同じ関数が渡るので、memo が効きます。
すでに書いたuseMemoはどうなるか
次に、useMemo と useCallback をすでに書いたコンポーネントを変換しました。
type ListProps = { data: number[]; onPick: (i: number) => void };
export function List({ data, onPick }: ListProps) {
const items = useMemo(() => data.filter((x) => x % 2 === 0), [data]);
const handle = useCallback((i: number) => onPick(i), [onPick]);
return <ul>{items.map((i) => <li key={i} onClick={() => handle(i)}>{i}</li>)}</ul>;
}
変換後のコードで useMemo と useCallback を探すと、残っていたのは import の行だけでした。
$ grep -n 'useMemo\|useCallback' out/existing.compiled.mjs
2:import { useMemo, useCallback } from 'react';
useMemo と useCallback の呼び出しは、コンパイラ自身のキャッシュ(if ($[0] !== data) { ... } の形)に置き換わっていました。今回の例では、手書きのメモ化とコンパイラのメモ化が二重にかかることはありません。ただし公式によると、手書きのメモ化を外すとコンパイル結果が変わることがあります。既存のコードから消すかどうかは、上の方針どおり、テストしてから決めるのが安全です。
コンパイラにスキップされると、自動のメモ化は効かない
コンパイラは、Reactのルール に従ったコードを前提にしています。コンパイラのデバッグについての公式ページ によると、コンパイラはルール違反の疑いがあるコードを見つけると、そのコンポーネントの最適化をスキップします。動作を変えてしまわないようにするためです。
そこで、さきほどの親に「レンダー中に props を書き換える」1行を足しました。書き換えられるように、props の型も変えています。これはReactのルール違反ですが、型の上では書き換えてよいことになっているため、tsc の型チェックはエラーなしで通ります。
type Data = number[] & { lastRendered?: number };
export function Parent({ data }: { data: Data }) {
data.lastRendered = Date.now(); // レンダー中に props を書き換える(ルール違反)
// 以下は同じ
コンパイラのログには、次のエラーが出ました。ただしビルド自体は失敗せず、このコンポーネントは変換されないまま出力されました。
CompileError line 16 | This value cannot be modified | Modifying component props or hook arguments is not allowed. Consider using a local variable instead
この1行を入れた状態で、useMemo なしの版と、useMemo / useCallback を手で書いた版を比べました。どちらもコンパイラを通しています。
$ node run.mjs ./out/bailout.compiled.mjs
./out/bailout.compiled.mjs: button=3 childRenders=4 effectRuns=4
$ node run.mjs ./out/bailout-manual.compiled.mjs
./out/bailout-manual.compiled.mjs: button=3 childRenders=1 effectRuns=1
コンパイラにスキップされたコンポーネントは、メモ化のないコードとして動きます。手書きの useMemo を消したあとでスキップされると、子の再描画やエフェクトの実行が、また毎回起きるようになります。
手書きの版は、コンパイラにスキップされても、子の描画とエフェクトの実行が1回で止まりました。既存のメモ化を「残すか、テストしてから外す」という公式の方針は、このような場面で役に立ちます。
なお、関数の先頭に "use no memo" と書いた場合も、コンパイラはその関数をスキップします。検証環境でも、これを付けた版は子の描画とエフェクトの実行が4回ずつになりました。公式は、この指示をデバッグのための一時的な手段と位置づけています。
ここまでの5つの版の計測結果を、1つのグラフにまとめました。どの版でも、エフェクトの実行回数は子の描画回数と同じでした。

今も手で書く意味がある場面
ここまでの結果と公式ドキュメントをふまえると、useMemo / useCallback を手で書く意味が残るのは、次の場面です。
- エフェクトの依存値を、意図したときだけ変わるように細かく制御したいとき(公式が挙げている例)
- コンパイラにスキップされるコンポーネントを書くとき(ルール違反がある、
"use no memo"が付いているなど) - 既存のコードで、外したときの影響をまだテストしていないとき
また、コンポーネントでもフックでもないふつうの関数は、コンパイラの対象外です。検証環境でも、そうした関数は変換されずにそのまま出力されました。紹介ページの、重い計算のメモ化についての説明 によると、コンパイラのメモ化は、コンポーネントやフックの間で共有されません。そのため同じページは、重い計算を複数のコンポーネントで使う場合に、React の外で独自にメモ化することも検討してよい、としています。
スキップされていないかを確かめる方法
どのコンポーネントがスキップされたかは、画面を見ても分かりません。筆者の検証では、Babelプラグインの logger オプション でイベントを受け取り、CompileSuccess と CompileError を出力して確かめました。
import type { Logger } from 'babel-plugin-react-compiler';
const logger: Logger = {
logEvent: (filename, event) => console.log(event.kind, 'fnName' in event ? event.fnName ?? '' : ''),
};
// Babel の設定: plugins: [['babel-plugin-react-compiler', { logger }]]
event は、イベントの種類によって形が変わる型です。検証環境で event.fnName と直接書くと、tsc でエラーになりました。そのため、'fnName' in event で fnName があるかを確かめてから読んでいます。
v1.0 の発表記事 によると、ルール違反は eslint-plugin-react-hooks の推奨設定でも見つけられます。このリンターは、コンパイラを入れていなくても使えるとされています。ただし、筆者はESLintでは試していません。
新しいコードでは、メモ化をコンパイラに任せ、useMemo / useCallback を書かずに済ませられます。既存のメモ化は、コンパイラにスキップされたときの保険にもなります。外すかどうかは、テストしてから決めましょう。





