React Compilerで手動メモ化は不要になるのか——導入前に知っておきたい注意点
はじめに
2025年10月、React Compiler 1.0がstable releaseとして公開されました。useMemoやuseCallback、React.memoを手動で書く必要がなくなると言われています。依存配列の書き忘れや過不足に悩まされてきた身としては嬉しい進化です。ただ、導入前に知っておいたほうがよさそうな点もあったので整理してみました。もしかしたら内容に誤りがあるかもしれませんので、正確な情報は公式ドキュメントをご確認ください。
React Compilerとは
従来は手動で書いていたメモ化を、コンパイラがビルド時に自動で挿入してくれるものです。Metaでは既にQuest Storeなどの本番環境で運用されており、初期ロードが最大12%改善、特定のインタラクションが2.5倍以上高速化したという実績が報告されています。
導入方法
React CompilerはBabel pluginとして提供されています。
npm install -D --save-exact babel-plugin-react-compiler@latest
Viteの場合はvite-plugin-reactのbabelオプションを通じて設定します。
// vite.config.ts
export default defineConfig({
plugins: [
react({
babel: {
plugins: [
['babel-plugin-react-compiler', { target: '18' }], // React 18の場合
],
},
}),
],
});
React 17以降で使用可能です。React 19未満の場合はreact-compiler-runtimeパッケージを追加し、targetオプションでバージョンを指定します。
React.memoとの違い
| 観点 | React.memo / useMemo | React Compiler |
|---|---|---|
| 適用範囲 | 明示的に指定 | 自動解析 |
| 粒度 | コンポーネント or 値単位 | 式レベルまで細かく(条件分岐後も可) |
| 依存関係 | 配列で明示 | 暗黙的 |
| 比較ロジックの指定 | 可能(React.memoの第2引数) | 不可 |
| 導入コスト | なし | ビルド設定が必要 |
大きな違いは依存配列がなくなることかと思います。
従来のコードでは依存関係が明示されていました。
const filtered = useMemo(() => {
return items.filter(item => item.category === category);
}, [items, category]);
React Compilerではこう書けます。
const filtered = items.filter(item => item.category === category);
シンプルになる一方で、何がメモ化されているのか、何に依存しているのかがコードから読み取れなくなります。レビューやデバッグのときに困る場面が出てくるかもしれません。
条件分岐後のメモ化
React Compilerの技術的なメリットとして、条件分岐の後でもメモ化できる点があります。
// 手動だとHooksのルール違反でエラー
function Component({ data }) {
if (!data) return null;
const processed = useMemo(() => expensiveProcess(data), [data]); // NG
}
// React Compilerなら問題なし
function Component({ data }) {
if (!data) return null;
const processed = expensiveProcess(data); // 自動でメモ化される
}
早期リターン後の変数も自動でメモ化対象になるため、コードの自由度が上がります。
導入前に確認しておきたいパターン
React Compilerは「Rules of React」に従ったコードを前提に最適化を行います。Rules of Reactとは、Reactが正しく動作するために守るべきルールのことで、主に以下のような内容です。
- コンポーネントとフックは純粋(同じ入力に対して同じ出力を返す)
- レンダリング中に副作用を実行しない
- propsやstateを直接変更しない
コンパイラがルール違反を検出した場合は、そのコンポーネントやフックをスキップして他のコードのコンパイルを続行します。スキップされた部分は従来どおりの動作になるため、アプリが壊れることはありません。
ただ、以下のようなコードがある場合は挙動を確認しておいたほうがよいかもしれません。
外部の可変オブジェクトを参照している
const data = globalCache[id]; // レンダリング中に外部の可変状態を読み取っている
レンダリング中に外部のミュータブルなオブジェクトを読み取ると、コンパイラが依存関係を正しく追跡できない可能性があります。
ミュータブルな配列操作
const sorted = items.sort((a, b) => a.id - b.id); // 元配列を破壊する
sort()は元の配列を直接変更するため、Reactの再レンダリング検知が正しく動作しない可能性があります。[...items].sort()やtoSorted()を使うのが安全です。
ref経由の値を計算に使う
const doubled = countRef.current * 2; // レンダリング中にrefを読み取っている
レンダリング中にref.currentを読み取ると、ESLintのreact-hooks/refsルールで警告されます。refの変更はリアクティブではないため、メモ化と相性が悪いです。
Date.now()やMath.random()
const timestamp = Date.now(); // 不純な関数のためコンパイラが最適化をスキップする可能性
これらは非決定的な値を返すため、コンパイラの純粋性チェックに引っかかります。ESLintのreact-hooks/purityルールでも警告が出ます。
React.memoの第2引数による比較関数
// React.memoでは第2引数で「いつ再レンダリングをスキップするか」を制御できた
const MemoizedComponent = React.memo(Component, (prevProps, nextProps) => {
// trueを返すと再レンダリングをスキップ
return prevProps.item.id === nextProps.item.id;
});
// React Compilerではこのような独自の比較ロジックを指定できない
React.memoの第2引数を使うと、propsの一部だけを比較したり、深い比較を行ったりできました。React Compilerではこの制御ができないため、そのような比較が必要な場合は設計自体を見直すことが推奨されているようです。
問題が発生した場合は、'use no memo'ディレクティブで特定のコンポーネントをオプトアウトできます。
function SuspiciousComponent() {
'use no memo';
// このコンポーネントはコンパイル対象外になる
}
コンパイラが最適化をスキップするパターン
ESLintのルールでは検出されないが、コンパイラが最適化をスキップするパターンがGitHub Issueやコミュニティで報告されています。将来のバージョンで対応される可能性もありますが、現時点(1.0)では以下のようなパターンに注意が必要なようです。
finally句の使用
try...catch...finallyやtry...finallyを使うと、そのコンポーネントは最適化対象から除外されるとの報告があります。
// 最適化されない可能性
async function fetchData() {
try {
const res = await fetch("/api/data");
return await res.json();
} finally {
console.log("完了");
}
}
対処法としては、finallyを使う処理をカスタムフックに切り出すことで、呼び出し側のコンポーネントは最適化対象になるようです。
try...catch内での条件分岐やOptional Chaining
try...catchブロック内でif文や?.を使うと最適化されないケースが報告されています。
// 最適化されない可能性
try {
const data = await res.json();
setValue(data.user?.name ?? "Unknown"); // try内でOptional Chaining
} catch {
console.error("Error");
}
条件分岐やOptional Chainingをtry...catchの外に出すと最適化されるとのことです。
コンポーネント内でのdynamic import
// 最適化されない可能性
async function handleClick() {
const { helper } = await import("./utils");
helper();
}
importをコンポーネント外の関数に切り出すと最適化されます。
これらのパターンは公式ドキュメントには明記されておらず、コミュニティでの報告に基づいています。実際の挙動はReact Compiler Playgroundで確認できます。
useMemo/useCallbackは削除すべきか
公式ドキュメントでは、新規コードではコンパイラに任せつつ、コンパイラの自動判断では意図した動作にならない場合にuseMemo/useCallbackを使うことを推奨しています。既存コードについては、削除するとコンパイル結果が変わる可能性があるため、そのまま残すか、十分にテストしてから削除することが推奨されています。
導入判断のフローチャート
ここまでの内容を踏まえて、導入判断の流れをまとめました。
ポイントは「純粋でイミュータブルなコードかどうか」です。React Compilerは純粋性を前提に最適化を行うため、ミュータブルな操作や副作用を含むコードが多いと、コンパイラがスキップする箇所が増えたり、予期しない挙動が起きる可能性があります。公式ドキュメントによると、メモ化の変更がuseEffectの発火タイミングに影響を与える場合もあるとのことです。イミュータブルな設計が徹底されているコードベースであれば、導入はスムーズにいきやすいです。
なお、E2Eテストが整備されていると、導入後の挙動確認が楽になります。テストがない場合でも導入は可能ですが、コンパイラのバージョンを固定(--save-exact)して慎重に進めるのがよいかもしれません。公式ドキュメントによると、マイナーバージョンやパッチバージョンでもメモ化の挙動が変わる可能性があるため、自動アップグレードではなく手動で確認しながら更新することが推奨されています。
現時点での所感
React Compiler 1.0はstable releaseとなり、Metaの本番環境で実績もあります。手動メモ化から解放されるのは大きなメリットです。依存配列のミスによるバグや、どこまでメモ化するか悩む時間が減るのは素直に嬉しいです。
導入を検討している場合はまずESLintプラグインを有効にして、コードベースにどれだけRules of React違反があるか確認することを良いかと思います。eslint-plugin-react-hooksのrecommendedまたはrecommended-latestプリセットにコンパイラ用のルールが含まれており、コンパイラをインストールしていなくても使えます。unsupported-syntaxルールでサポートされていない構文を検出できますが、finally句やtry-catch内の条件分岐などがこのルールで検出されるかは確認できていません。確実に把握したい場合はReact Compiler Playgroundで実際に試すか、react-compiler-markerのようなツールを使うとよいかもしれません。
なお、以前はeslint-plugin-react-compilerという別パッケージがありましたが、現在はeslint-plugin-react-hooksに統合されています。既にインストールしている場合は削除してeslint-plugin-react-hooks@latestに移行することが推奨されています。
Discussion