はじめに
React 19と同時に話題になっていたReact Compilerが2025年10月に1.0として安定版になり、Next.js 16では
reactCompiler
オプションがexperimentalから昇格して正式サポートになりました。
「
useMemo
や
useCallback
を手で書かなくてよくなる」という事をNext.jsのサンプルプロジェクトで実際に試してみました。
環境は以下の通りです。
- Next.js 16.3.5
- React 19.3.0
- babel-plugin-react-compiler 1.0.0
React Compilerとは
React Compilerは、コンポーネントとフックのコードをビルド時に解析し、自動でメモ化コードを差し込むBabelプラグインです。
これまで再レンダリングの最適化は、開発者が
useMemo
/
useCallback
/
React.memo
を「どこに入れるべきか」を判断して手書きしていました。React Compilerを使うと、ビルド時に
useMemo
/
useCallback
/
React.memo
に相当するメモ化コードをコンパイラが自動で差し込みます。開発者はどこにメモ化を入れるかを判断する必要がなくなり、ソースコードにはこれらを書かなくて済みます。
Next.jsの中でReact Compilerはどう動いているのか
React Compilerは「Babelプラグイン」として提供されていますが、Next.jsの標準コンパイラはBabelではなくSWCです。この2つの関係を整理しておきます。
BabelとSWC
どちらもJavaScript / TypeScriptのトランスパイラです。ブラウザはJSXやTypeScriptをそのまま実行できないので、ビルド時に次のような変換が必要になります。
| 1 2 3 4 5 | // 書いたコード const el = <div className="x">{name}</div>; // ブラウザが実行できる形 const el = _jsx("div", { className: "x", children: name }); |
| Babel | SWC | |
|---|---|---|
| 実装言語 | JavaScript | Rust |
| 速度 | 遅い | Babelの数倍〜数十倍速い |
| プラグイン | JSで書けるので豊富 | Babel用プラグインは動かない |
| Next.jsでの扱い | v11までの標準 | v12から標準 |
React CompilerはReactチームがBabelプラグインとして開発してきたため、パッケージ名が
babel-plugin-react-compiler
になっているようです。
Next.js 12以降の本体はSWCでビルドしていますが、React CompilerはBabelプラグインなので、SWCからそのまま呼ぶことはできません。そこで以下のように2段構えになっているようです。
- 通常のトランスパイルはSWCで高速に処理する
-
reactCompiler: trueのとき、JSXやReact Hooksを含むファイルだけをSWCが判別し、そのファイルにだけBabelを起動してbabel-plugin-react-compilerを適用する
全ファイルにBabelをかけるより速いものの、対象ファイルの分だけBabel(Node.js上のJS処理)が走るので、React Compilerを有効にするとビルドは多少遅くなります。ドキュメントにも「デフォルトのRustベースのコンパイラと比べるとわずかに遅くなることはあるが、影響は小さく局所的」の旨が書かれています。
導入
まずBabelプラグインをインストールします。
| 1 | npm install -D babel-plugin-react-compiler |
次に
next.config.ts
で有効化します。
| 1 2 3 4 5 6 7 8 | // next.config.ts import type { NextConfig } from "next"; const nextConfig: NextConfig = { reactCompiler: true, }; export default nextConfig; |
実際に動かしてみる(検証)
検証用コンポーネント
親コンポーネントに「テキスト入力」と「数値n」の2つのstateを持たせ、nだけを受け取る重い子コンポーネント
HeavyChild
を置きました。
テキスト入力は
HeavyChild
とは無関係なstateなので、文字を打っても
HeavyChild
が再レンダリングされないことが期待する挙動です。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | 'use client'; import { useState } from 'react'; // n × 100万回ループして、数えた回数を返すだけの重い処理 function heavyCount(n: number): number { let count = 0; for (let i = 0; i < n * 1_000_000; i++) { count++; } return count; } function HeavyChild({ n }: { n: number }) { const start = performance.now(); const value = heavyCount(n); console.log(`重い子コンポーネント render: count(${n}) took ${(performance.now() - start).toFixed(1)}ms`); return ( <div className="p-4 bg-white rounded-lg shadow"> <p className="text-lg"> count({n}) = {value.toLocaleString()} </p> <p className="text-sm text-gray-500">計算時間はコンソールに出力</p> </div> ); } export default function CompilerDemo() { const [text, setText] = useState<string>(''); const [n, setN] = useState<number>(100); console.log('親コンポーネント render'); return ( <main> <label>テキスト入力(HeavyChild とは無関係な state)</label> <input type="text" value={text} onChange={(e) => setText(e.target.value)} /> <p>入力値: {text}</p> <button onClick={() => setN(n + 100)}> 子コンポーネントの値を増やす(HeavyChild を再計算させる) </button> <HeavyChild n={n} /> </main> ); } |
useMemo
も
useCallback
も
React.memo
も書いていません。
heavyCount
はnに比例して時間がかかるので、ボタンでnを100ずつ増やすと再計算のコストが増えていきます。
無関係なstateを更新したとき、重い子は再描画されるか
テキスト入力に
a
b
c
と3文字打ったときのコンソール出力を、
reactCompiler
のon/offで比較しました。
reactCompiler: false
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | 親コンポーネント render 親コンポーネント render 重い子コンポーネント render: count(100) took 66.5ms 重い子コンポーネント render: count(100) took 60.2ms 親コンポーネント render ← 'a' を入力 親コンポーネント render 重い子コンポーネント render: count(100) took 55.0ms 重い子コンポーネント render: count(100) took 39.5ms 親コンポーネント render ← 'b' を入力 親コンポーネント render 重い子コンポーネント render: count(100) took 38.2ms 重い子コンポーネント render: count(100) took 38.9ms 親コンポーネント render ← 'c' を入力 親コンポーネント render 重い子コンポーネント render: count(100) took 38.3ms 重い子コンポーネント render: count(100) took 38.7ms |
1文字打つたびに
HeavyChild
が再レンダリングされ、そのたびに
heavyCount(100)
を計算し直しています。これが、手動でメモ化していないコードの「普通の」挙動です。
reactCompiler: true
| 1 2 3 4 5 6 7 8 9 | 親コンポーネント render 親コンポーネント render 重い子コンポーネント render: count(100) took 42.0ms 親コンポーネント render ← 'a' を入力 親コンポーネント render 親コンポーネント render ← 'b' を入力 親コンポーネント render 親コンポーネント render ← 'c' を入力 親コンポーネント render |
3文字打っても
重い子コンポーネント render
は1回も出ません。親の
CompilerDemo
はstateが変わるので再レンダリングされますが、子の
HeavyChild
はスキップされています。
「子コンポーネントの値を増やす」ボタンを押すと、
n
が変わるので
HeavyChild
は再計算されます。
| 1 2 3 | 親コンポーネント render 親コンポーネント render 重い子コンポーネント render: count(200) took 96.0ms |
再レンダリングされない仕組み
React Compilerが差し込むメモ化コードの仕組みは次のとおりです。
- コンポーネント内で計算される値やJSXを、依存する値ごとに分解する
- それぞれをキャッシュ用のスロット(
useMemoCache)に保存し、依存値が変わっていなければ前回の結果を再利用する - 依存値が変わったときだけ、その部分を再計算する
これを、
HeavyChild
が
next build
の成果物(
.next/static/chunks/*.js
)でどう変わっているかで確認します。成果物はバンドル・minifyされた1行のコードなので、読みやすい形に整形し、説明のために
(1)
〜
(4)
のコメントを付けています。
有効化前の子コンポーネント
React Compiler有効化前のビルド成果物は、ソースコードをそのまま変換したものです。
| 1 2 3 4 5 6 7 8 9 | function l({ n: e }) { let s = performance.now(), n = (function (e) { let t = 0; for (let s = 0; s < 1e6 * e; s++) t++; return t; })(e); console.log(`重い子コンポーネント render: count(${e}) took ${(performance.now() - s).toFixed(1)}ms`); return jsxs("div", { className: "p-4 bg-white rounded-lg shadow", children: [ jsxs("p", { className: "text-lg", children: ["count(", e, ") = ", n.toLocaleString()] }), jsx("p", { className: "text-sm text-gray-500", children: "計算時間はコンソールに出力" }), ] }); } |
有効化後の子コンポーネント
React Compiler有効化後は次のようになります。
x
がpropsの
n
、
h
は計測用の開始時刻です。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | function l(e) { let c, l, n, r, a, o, i, m, u, d = useMemoCache(17), // (1) キャッシュスロットを17個確保 { n: x } = e, h = performance.now(); if (d[0] !== x) { // (2) props の n が前回と違うときだけ let e = (function (e) { let t = 0; for (let s = 0; s < 1e6 * e; s++) t++; return t; })(x); // heavyCount を実行 console.log(`重い子コンポーネント render: count(${x}) took ${(performance.now() - h).toFixed(1)}ms`); o = "p-4 bg-white rounded-lg shadow"; c = "text-lg"; l = "count("; n = x; r = ") = "; a = e.toLocaleString(); d[0] = x; d[1] = c; d[2] = l; d[3] = n; d[4] = r; d[5] = a; d[6] = o; } else { c = d[1]; l = d[2]; n = d[3]; r = d[4]; a = d[5]; o = d[6]; // 同じなら前回の値を取り出す } if (d[7] !== c || d[8] !== l || d[9] !== n || d[10] !== r || d[11] !== a) { // (3) JSX 要素も依存値ごとにキャッシュ i = jsxs("p", { className: c, children: [l, n, r, a] }); d[7] = c; d[8] = l; d[9] = n; d[10] = r; d[11] = a; d[12] = i; } else { i = d[12]; } if (d[13] === Symbol.for("react.memo_cache_sentinel")) { // 依存値のない JSX は初回だけ生成 m = jsx("p", { className: "text-sm text-gray-500", children: "計算時間はコンソールに出力" }); d[13] = m; } else { m = d[13]; } if (d[14] !== o || d[15] !== i) { u = jsxs("div", { className: o, children: [i, m] }); d[14] = o; d[15] = i; d[16] = u; } else { u = d[16]; } return u; // (4) n が同じなら前回と同一の要素オブジェクトを返す } |
- (1)
useMemoCache(17)で、このコンポーネント専用のキャッシュ配列を確保します。スロット数はコンポーネント内で分解された値の数で決まり、HeavyChildでは17個です - (2)
heavyCountの呼び出しが「propsのnが前回と違うときだけ」になっています。手書きのuseMemo(() => heavyCount(n), [n])と同等のコードが自動生成されたことになります。今回のコードではconsole.logも同じブロックに入っているので、nが同じときはログ自体が出ません - (3) 計算結果だけでなく、
<p>や<div>のJSX要素も依存値ごとにキャッシュされます。依存値が同じなら、新しい要素を作らずに前回のオブジェクトを取り出します - (4) その結果、
nが同じときのreturn uは、前回のレンダーで返したものと同一の要素オブジェクトを返します。Reactは前回と同一のオブジェクト(参照が同じ)を受け取ると、その下の差分比較もDOM更新も行いません
親の
CompilerDemo
側も同じ形にコンパイルされていて、
<HeavyChild n={n} />
の要素オブジェクトが親のstate
n
に対してキャッシュされます。
n
が同じなら親が再レンダリングされても前回と同一のオブジェクトが渡されるため、Reactは
HeavyChild
関数を呼びません。これが
React.memo
相当の効果となります。
手書きの
memo
/
useMemo
と比べると
React Compilerが無ければ、開発者が
React.memo
と
useMemo
を次のように手で書いて同じ効果を実現します。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | import { memo, useMemo } from 'react'; // ① memo: propsのnが前回と同じなら、HeavyChild関数の呼び出しごとスキップする const HeavyChild = memo(function HeavyChild({ n }: { n: number }) { const start = performance.now(); // ② useMemo: 依存配列[n]が前回と同じなら、heavyCountを実行せず前回の結果を返す const value = useMemo(() => heavyCount(n), [n]); console.log(`重い子コンポーネント render: count(${n}) took ${(performance.now() - start).toFixed(1)}ms`); return ( <div className="p-4 bg-white rounded-lg shadow"> <p className="text-lg">count({n}) = {value.toLocaleString()}</p> <p className="text-sm text-gray-500">計算時間はコンソールに出力</p> </div> ); }); |
他
Rules of Reactを守っていないコンポーネントはスキップされる
React Compilerは、Rules of React(Reactのルール)を守っていないコンポーネントをコンパイル対象からスキップします(壊しはしません)。最適化されないまま従来通り動きます。
"use no memo"
でコンポーネント単位にオプトアウトする
特定のコンポーネントだけ最適化の対象外にしたいケースのために
"use no memo"
ディレクティブが用意されています。例えば以下のように書くと
HeavyChild
だけがキャッシュなしの素のコードになります。
| 1 2 3 4 | function HeavyChild({ n }: { n: number }) { 'use no memo'; // ... } |
さいごに
next.config.ts
に1行足すだけで、
useMemo
/
useCallback
/
React.memo
を書いていないコードから、手書き相当のメモ化コードが生成されることを確認できました。この機能はかなり便利だと思うので、今後は積極的に使用の検討をしようと思います。