useMemoを書いた数だけ、バグが増えていた
正直に告白します。私は useMemo の依存配列を間違えて、本番環境で古いデータが表示され続けるバグを出したことがあります。しかも2回。
手動メモ化は「パフォーマンス最適化」の皮を被った複雑性の注入です。useMemo、useCallback、React.memo を正しく使いこなすには、Reactの再レンダリングの仕組みを深く理解し、依存配列を一つも間違えず、チームメンバー全員がそのルールを守る必要があります。現実的ではありません。
React Compiler v1.0は、この問題に対するReactチームの回答です。ビルド時にコンポーネントを解析し、自動でメモ化を適用する。開発者は useMemo を書く代わりに、ビジネスロジックに集中できる。この記事では、React Compilerの仕組みから既存プロジェクトへの段階的導入、そして実際にハマる落とし穴まで、実務で本当に必要な知識をまとめます。
React Compilerとは何か
React Compilerはビルド時に動作するBabelプラグインです。コンポーネントとカスタムフックのコードを静的解析し、再レンダリング時に変更されていない部分の再計算をスキップするコードを自動生成します。
従来のReactでは、親コンポーネントが再レンダリングされると子コンポーネントも無条件に再レンダリングされていました。これを防ぐために React.memo でラップし、プロップスの useMemo や useCallback で参照の安定性を保証する——というのが定番パターンでした。
React Compilerはこのボイラープレートを不要にします。
// Before: 手動メモ化の地獄
import { useMemo, useCallback, memo } from "react";
const UserList = memo(function UserList({ users, onSelect }) {
const sorted = useMemo(
() =>
users
.filter((u) => u.active)
.sort((a, b) => a.name.localeCompare(b.name)),
[users],
);
const handleSelect = useCallback((id) => onSelect(id), [onSelect]);
return sorted.map((u) => (
<UserCard key={u.id} user={u} onSelect={handleSelect} />
));
});
// After: React Compilerに任せる
function UserList({ users, onSelect }) {
const sorted = users
.filter((u) => u.active)
.sort((a, b) => a.name.localeCompare(b.name));
const handleSelect = (id) => onSelect(id);
return sorted.map((u) => (
<UserCard key={u.id} user={u} onSelect={handleSelect} />
));
}
コード量が減っただけではありません。手動メモ化では見落としがちなインライン関数のメモ化漏れも、コンパイラが自動で処理します。上の例で () => handleSelect(item) のようなインライン関数を map 内で書いても、コンパイラは適切にメモ化してくれます。
導入手順: 3ステップで始める
ステップ1: インストール
pnpm add -D babel-plugin-react-compiler
ステップ2: フレームワーク設定
Next.jsの場合、next.config.js に1行追加するだけです。
// next.config.js
const nextConfig = {
experimental: {
reactCompiler: true,
},
};
module.exports = nextConfig;
Viteの場合は vite.config.ts で設定します。
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
react({
babel: {
plugins: ["babel-plugin-react-compiler"],
},
}),
],
});
ステップ3: ESLintプラグインの設定
React Compilerが正しく動作するには、コードが「Rules of React」に従っている必要があります。ESLintプラグインで違反を事前に検出しましょう。
// eslint.config.js
import reactHooks from "eslint-plugin-react-hooks";
export default [reactHooks.configs.flat.recommended];
v1.0で追加された set-state-in-render ルールは特に重要です。レンダリング中に setState を呼ぶパターンは、コンパイラのメモ化と相性が悪く、無限ループを引き起こす可能性があります。
コンパイラが最適化「しない」もの
ここからが本題です。多くの記事が「useMemoが不要になる」と書いていますが、React Compilerは万能ではありません。私のポジションは明確で、「コンパイラはレンダリングを最適化するが、アーキテクチャは最適化しない」です。
1. コンポーネント外の関数
React Compilerはコンポーネントとカスタムフック内のコードのみをメモ化します。ユーティリティ関数やモジュールスコープの処理は対象外です。
// これはメモ化されない
function heavyCalculation(data) {
return data.reduce((acc, item) => {
// 重い計算処理
return acc + complexOperation(item);
}, 0);
}
// この中の呼び出しはメモ化される
function Dashboard({ data }) {
const result = heavyCalculation(data);
return <Chart value={result} />;
}
Dashboard 内での heavyCalculation(data) の呼び出し結果はメモ化されますが、heavyCalculation 関数自体の実行は毎回行われます。data が巨大で計算が重い場合、コンポーネント外でのキャッシュ戦略を別途検討する必要があります。
2. レンダリングの「WHETHER」は変えない
コンパイラは「HOWを最適化するが、WHETHERは最適化しない」——つまり、レンダリングの方法は最適化しても、レンダリングするかどうかの判断は変えません。
// コンパイラはこれを最適化できない
function Feed({ items }) {
// 10,000件のアイテムを全部レンダリング
return (
<div>
{items.map((item) => (
<FeedItem key={item.id} item={item} />
))}
</div>
);
}
このケースではウィンドウイング(仮想スクロール)が必要です。react-window や @tanstack/virtual を使って、画面に見えている要素だけをレンダリングする設計が求められます。コンパイラの守備範囲外です。
3. 初期読み込みは改善しない
React Compilerは更新パフォーマンスに焦点を当てています。初回レンダリングのFCPやLCPは改善されないどころか、メモ化コードの追加でバンドルサイズがわずかに増加し、初期読み込みがやや遅くなるケースも報告されています。
初期読み込みの最適化には、コード分割(React.lazy)やサーバーコンポーネントなど、別のアプローチが引き続き必要です。
実務でハマる3つの落とし穴
落とし穴1: react-hook-formとの相性問題
これが最も多くの開発者を悩ませる問題です。react-hook-formは内部でProxyを使い、formState へのアクセスを追跡しています。しかし、useForm が返すオブジェクトの参照自体は変わらないため、React Compilerは「変更なし」と判断して再レンダリングをスキップしてしまいます。
症状として、フォームに値を入力してもUIに反映されない、バリデーションエラーが表示されないといった挙動が発生します。
対処法: 問題が発生するコンポーネントに "use no memo" ディレクティブを追加します。
"use no memo";
import { useForm } from "react-hook-form";
function ContactForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm();
// ...
}
"use no memo" はファイルまたは関数の先頭に記述することで、そのスコープをコンパイラの最適化対象から除外します。同様の問題は useInfiniteQuery や useReactTable など、オブジェクト参照を維持しつつ内部状態を変更するライブラリでも発生します。
落とし穴2: devとbuildで挙動が異なる
React Compilerは本番ビルドでのみ完全に有効化されます。next dev と next build で挙動が変わるケースが頻繁に報告されており、開発中は問題なく動作していたフォームが、本番ビルドでは入力を受け付けないといった事態が起こりえます。
対処法: 機能追加やライブラリ更新のたびに next build を実行して検証する習慣をつけましょう。CIパイプラインにE2Eテストを組み込むのが最も確実です。
# 開発・本番を並行テストするTips
# .env.development と .env.production で出力ディレクトリを分ける
NEXT_BUILD_DIR=.next-dev # 開発用
NEXT_BUILD_DIR=.next # 本番用
落とし穴3: 既存のuseMemoを安易に削除しない
コンパイラ導入後、既存の useMemo や useCallback を一括削除したくなりますが、これは危険です。既存のメモ化を削除するとコンパイラの出力が変わる可能性があり、特に useEffect の依存配列にメモ化値を使っている場合、エフェクトの発火タイミングが変わってバグを引き起こします。
// 危険: useMemoを削除するとuseEffectの挙動が変わる可能性
function Analytics({ data }) {
const processed = useMemo(() => transform(data), [data]);
useEffect(() => {
trackEvent("data_processed", processed);
}, [processed]); // processedの参照安定性に依存
}
公式の推奨は「新しいコードでは useMemo/useCallback を書かない。既存のコードはそのまま残すか、十分なテストの上で慎重に削除する」です。
段階的導入戦略
既存プロジェクトにいきなり全体適用するのは推奨しません。以下の3段階で進めましょう。
フェーズ1: ESLint導入(1日)
まずESLintプラグインだけを導入し、Rules of React違反を洗い出します。レンダリング中のミューテーションや副作用があれば、コンパイラ導入前に修正が必要です。
フェーズ2: ディレクトリ単位で段階適用(1-2週間)
コンパイラ設定で対象ディレクトリを限定できます。
// next.config.js
const nextConfig = {
experimental: {
reactCompiler: {
compilationMode: "annotation",
},
},
};
compilationMode: 'annotation' にすると、"use memo" ディレクティブを記述したファイルだけがコンパイル対象になります。影響範囲を限定しながら、問題がないか確認できます。
フェーズ3: 全体適用 + モニタリング
E2Eテストが十分にある状態で全体適用に切り替えます。React DevToolsでコンポーネント名の横に「Memo ✨」バッジが表示されていれば、コンパイラが正しく動作しています。
パフォーマンス改善の実測値
公式発表によると、React Compilerを導入した大規模アプリケーションでは以下の改善が確認されています。
| 指標 | 改善幅 |
|---|---|
| 初期読み込み・ページ遷移 | 最大12%向上 |
| 特定のインタラクション | 2.5倍以上高速化 |
| メモリ使用量 | 変化なし |
ただし、これはすでに大量の手動メモ化が施されていたアプリケーションでの数値です。もともとメモ化が少ないプロジェクトでは改善幅がさらに大きくなる可能性がある一方、すでに適切にメモ化されているプロジェクトでは体感できる差が出ないケースもあります。
あるプロダクション事例では、6ヶ月間の運用で「一部のケースではパフォーマンスが60%改善、別のケースでは変化なし」という結果が報告されています。コンパイラは最適化できないコードをサイレントにスキップするため、DevToolsでの確認が重要です。
まとめ: 「書かない最適化」という新常識
React Compilerの本質は「手動メモ化の自動化」ではなく、開発者がパフォーマンス最適化を意識しなくていい世界の実現です。
ただし、魔法ではありません。react-hook-formのようなProxy依存ライブラリとの相性問題、dev/build間の挙動差異、アーキテクチャレベルの最適化が手動のままであることは理解しておく必要があります。
私の推奨する導入アプローチは以下のとおりです。
- 新規プロジェクト: 最初からコンパイラを有効化。
useMemo/useCallbackは原則書かない - 既存プロジェクト: ESLint → annotation mode → 全体適用の3段階で導入
- 共通: E2Eテストを整備し、本番ビルドでの検証を習慣化する
useMemo を書く時間を、ユーザー体験の設計に使いましょう。それがReact Compilerが開発者に与えてくれる最大の価値です。