この記事の要点
• useMemo/useCallback/React.memoの手動最適化をビルド時に自動化するコンパイラ
• Babelプラグインとして動作し、Rules of Reactに従っていれば自動で最適化
• ESLintプラグインで事前にコンパイラ互換性を確認可能
React Compiler は、React チームが公式に開発している自動メモ化コンパイラです。これまで手動で useMemo / useCallback / React.memo を貼り付けて行っていた再レンダリング最適化を、ビルド時に自動化してくれる仕組みです。本記事では、React Compiler の動作原理、導入手順、書き方の変化、注意点、既存プロジェクトを移行するときのベストプラクティスを整理します。
概要
flowchart LR
Source["JSX / Hooks コード"] --> Compiler["React Compiler (Babel plugin)"]
Compiler --> Output["メモ化済み JS"]
Output --> Bundler["Vite / Next.js / webpack"]
Bundler --> Browser["ブラウザ実行"]
Compiler -. lint .-> ESLint["eslint-plugin-react-compiler"]
React Compiler は Babel プラグインとして動作し、React コンポーネントとフックを解析、必要に応じてメモ化コードを自動挿入します。開発者は素直に書くだけで、React の Rules of React に従っている限り適切な最適化が得られる、という設計思想です。
主な特徴
1. 自動メモ化
これまで:
function Item({ item, onSelect }: Props) {
const label = useMemo(() => formatLabel(item), [item]);
const handleClick = useCallback(() => onSelect(item.id), [onSelect, item.id]);
return <button onClick={handleClick}>{label}</button>;
}
React Compiler 導入後:
function Item({ item, onSelect }: Props) {
const label = formatLabel(item);
const handleClick = () => onSelect(item.id);
return <button onClick={handleClick}>{label}</button>;
}
useMemo / useCallback を書かなくても、コンパイラが依存関係を解析してメモ化してくれます。
2. Rules of React の検査
React Compiler は「Rules of React」(純粋性、Hooks のルール、不変性) を前提として動作します。eslint-plugin-react-compiler がこのルール違反を検出してくれます。
npm install --save-dev eslint-plugin-react-compiler
{
"plugins": ["react-compiler"],
"rules": {
"react-compiler/react-compiler": "error"
}
}
3. 既存コードベースへの段階的導入
ディレクトリ単位やファイル単位でオプトインできるため、巨大プロジェクトでも段階的に有効化できます。
4. フレームワーク統合
Next.js、Vite、Expo など主要フレームワークが React Compiler のオプションをドキュメント化しており、最小設定で導入可能です。
5. 開発体験の向上
依存配列の書き忘れ・書き間違いに起因するバグが減り、コードもシンプルになります。
導入手順
Vite + React の場合
npm install --save-dev babel-plugin-react-compiler
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
react({
babel: {
plugins: [["babel-plugin-react-compiler", {}]],
},
}),
],
});
Next.js の場合
npm install --save-dev babel-plugin-react-compiler
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
reactCompiler: true,
},
};
module.exports = nextConfig;
公式ドキュメントの最新の設定方法に従ってください。フラグ名はバージョンによって変わることがあります。
実践サンプル: リスト表示の最適化
Before
import { useMemo, useCallback } from "react";
type Todo = { id: string; title: string; done: boolean };
export function TodoList({
todos,
onToggle,
}: {
todos: Todo[];
onToggle: (id: string) => void;
}) {
const visible = useMemo(() => todos.filter((t) => !t.done), [todos]);
const handle = useCallback((id: string) => onToggle(id), [onToggle]);
return (
<ul>
{visible.map((t) => (
<li key={t.id}>
<button onClick={() => handle(t.id)}>{t.title}</button>
</li>
))}
</ul>
);
}
After (React Compiler)
type Todo = { id: string; title: string; done: boolean };
export function TodoList({
todos,
onToggle,
}: {
todos: Todo[];
onToggle: (id: string) => void;
}) {
const visible = todos.filter((t) => !t.done);
return (
<ul>
{visible.map((t) => (
<li key={t.id}>
<button onClick={() => onToggle(t.id)}>{t.title}</button>
</li>
))}
</ul>
);
}
コードがすっきりし、メモ化の漏れによるバグも起きません。
比較表
| 観点 | 手動メモ化 (従来) | React Compiler |
|---|---|---|
| 記述量 | 多い | 少ない |
| 漏れ・ミス | 起きやすい | 起きにくい |
| 学習コスト | 高 | 低 |
| デバッグ | 自分で追える | コンパイラ依存 |
| 既存コード対応 | 都度修正 | 段階導入可能 |
ベストプラクティス
1. ESLint プラグインを必ず入れる
eslint-plugin-react-compiler の警告ゼロを目標にすれば、コンパイラが安全に動く土台ができます。
2. Rules of React を再確認
「コンポーネントは純粋」「フックはトップレベルで呼ぶ」「props/state は不変」といった基本原則を守っていることが前提です。
3. ディレクトリ単位で段階導入
大規模プロジェクトでは新しい機能ディレクトリから有効化し、既存コードは順次クリーンアップしていきます。
4. プロファイラで効果測定
React DevTools Profiler で再レンダー回数を計測し、導入前後の差分を確認しましょう。
5. メモ化の手書きを基本やめる
導入後はあえて useMemo / useCallback を書かない方針にすることで、コードレビュー負荷も下がります。
注意点・落とし穴
- 副作用のある関数: 純粋でないコードはコンパイラが正しく扱えない場合があります。副作用は
useEffect内に閉じ込める。 - クラスコンポーネント非対応: 関数コンポーネント前提の最適化です。
- デバッグ難度: 自動挿入されたコードを直接読まないと挙動が追えないケースがあるため、Source Map と DevTools を使いこなしましょう。
- ライブラリ互換性: 一部の独自フックや高度なラッパーで誤検知が出ることがあります。アップデート情報を追う必要があります。
- 型エラーは別問題: TypeScript の型エラーは React Compiler とは独立しているため、型整備は引き続き重要です。
パフォーマンス観点
- 再レンダー回数の削減
- 不要な子コンポーネント更新の抑制
- バンドルサイズへの影響は限定的 (メモ化コードが追加される分の増加はある)
- 実アプリでは UX 改善 (入力遅延の低減等) が体感しやすい
FAQ
Q1. すべての useMemo / useCallback を消すべき?
A. 段階的に消していけば OK です。動作確認しながら少しずつ削減していきましょう。
Q2. パフォーマンスは必ず良くなる?
A. ほとんどのケースで改善しますが、極端に最適化された手書きコードからの移行では中立になる場合もあります。
Q3. SSR / RSC とも併用できますか?
A. 可能です。Next.js の App Router など RSC 環境とも統合が進んでいます。
Q4. テストへの影響は?
A. 振る舞いは変わらないため、既存のテストはそのまま動くのが基本です。
Q5. デバッグはどうする?
A. React DevTools と Source Map を活用し、必要に応じて変換前のソースで挙動を追いましょう。
まとめ
React Compiler は、これまで開発者が肩代わりしていた最適化作業を React 自身に戻す大きな一歩です。Rules of React を守った素直なコードを書くだけで、再レンダリング最適化と保守性の高さを両立できます。新規プロジェクトでは積極的に有効化し、既存プロジェクトでは ESLint プラグインから始めて段階的に導入するのがおすすめです。React の書き方そのものをシンプルに戻してくれる、エコシステム全体の前進となる機能です。
より大きなサンプル: 検索付きユーザーテーブル
Before: 手動メモ化の塊
import { useMemo, useCallback, useState } from "react";
type User = { id: string; name: string; email: string; role: "admin" | "user" };
export function UserTable({ users, onDelete }: { users: User[]; onDelete: (id: string) => void }) {
const [query, setQuery] = useState("");
const [sortKey, setSortKey] = useState<keyof User>("name");
const filtered = useMemo(() => {
const q = query.toLowerCase();
return users.filter(
(u) => u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q),
);
}, [users, query]);
const sorted = useMemo(() => {
return [...filtered].sort((a, b) => String(a[sortKey]).localeCompare(String(b[sortKey])));
}, [filtered, sortKey]);
const handleDelete = useCallback(
(id: string) => {
if (confirm("削除しますか?")) onDelete(id);
},
[onDelete],
);
const handleSort = useCallback((key: keyof User) => setSortKey(key), []);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="検索" />
<table>
<thead>
<tr>
<th onClick={() => handleSort("name")}>Name</th>
<th onClick={() => handleSort("email")}>Email</th>
<th onClick={() => handleSort("role")}>Role</th>
<th></th>
</tr>
</thead>
<tbody>
{sorted.map((u) => (
<tr key={u.id}>
<td>{u.name}</td>
<td>{u.email}</td>
<td>{u.role}</td>
<td>
<button onClick={() => handleDelete(u.id)}>削除</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
After: React Compiler を信頼してシンプルに
import { useState } from "react";
type User = { id: string; name: string; email: string; role: "admin" | "user" };
export function UserTable({ users, onDelete }: { users: User[]; onDelete: (id: string) => void }) {
const [query, setQuery] = useState("");
const [sortKey, setSortKey] = useState<keyof User>("name");
const q = query.toLowerCase();
const filtered = users.filter(
(u) => u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q),
);
const sorted = [...filtered].sort((a, b) =>
String(a[sortKey]).localeCompare(String(b[sortKey])),
);
const handleDelete = (id: string) => {
if (confirm("削除しますか?")) onDelete(id);
};
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="検索" />
<table>
<thead>
<tr>
<th onClick={() => setSortKey("name")}>Name</th>
<th onClick={() => setSortKey("email")}>Email</th>
<th onClick={() => setSortKey("role")}>Role</th>
<th></th>
</tr>
</thead>
<tbody>
{sorted.map((u) => (
<tr key={u.id}>
<td>{u.name}</td>
<td>{u.email}</td>
<td>{u.role}</td>
<td>
<button onClick={() => handleDelete(u.id)}>削除</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
コンポーネントの本質的なロジックだけが残り、依存配列のメンテナンスから解放されます。
ESLint プラグインのルール例
eslint-plugin-react-compiler は次のような違反を検出します。
- ref を render 中に書き換えている
- props を直接 mutate している
- フックを条件分岐の中で呼んでいる
- レンダー中に setState を無条件で呼んでいる
これらは React 自体のルール違反でもあるため、Compiler の有無に関わらず修正すべきものです。導入の副作用として、コードベース全体の健全性が向上します。
既存プロジェクト移行チェックリスト
- React のバージョンが対応版か確認
eslint-plugin-react-compilerを導入し違反を 0 にする- 副作用は
useEffectに閉じ込める - props / state を不変に扱う
- 1 ディレクトリだけ Compiler を有効化し、回帰テストを実行
- React DevTools Profiler で Before/After を測定
- 問題なければ全体に展開し、
useMemo/useCallbackの手書きを段階削減 - CI に Profiler ベースの簡易ベンチを入れて再発防止
デバッグのコツ
- React DevTools の “Highlight updates” で再レンダーを可視化
- Source Map を有効にして元のコードに対応付け
- 問題が起きた箇所だけ一時的に Compiler 対象から外して切り分け
- ライブラリ起因の場合はバージョン固定 + Issue 報告
ライブラリ作者への影響
ライブラリを公開している場合、Compiler が解析しやすい形でフックを書くことが重要になります。具体的には、
- 引数を mutate しない
- 戻り値を再利用可能にする
- 副作用は明示的に
useEffect経由にする - ドキュメントに「Rules of React 準拠」を明記する
これらを守ることで、利用者側のプロジェクトで Compiler を有効化したときに問題なく動作します。
よくある質問への深掘り
Q. Profiler で速くなったように見えないことがある
ベンチマーク条件を確認してください。React の StrictMode 下では開発時に意図的に二重レンダーが発生します。本番ビルドで計測しないと効果が見えにくいです。
Q. memo() は不要になる?
ほとんどのケースで不要になりますが、外部から渡される props が頻繁に新規参照になるコンポーネントを「念のため」境界として包む使い方は残ります。
Q. Recoil / Zustand / Jotai との相性は?
これらの状態管理ライブラリは React のルールに従って実装されているため、Compiler とも問題なく併用できます。ただし最新版を使うこと。
Q. Storybook での挙動は?
Storybook 自体のビルドにも Compiler を組み込めます。コンポーネント単位の検証時から最適化済みコードを確認できる利点があります。
アンチパターンとリファクタ例
アンチパターン: render 中の副作用
function Bad({ items }: { items: Item[] }) {
items.sort((a, b) => a.id - b.id); // 引数を mutate
return <List items={items} />;
}
修正版
function Good({ items }: { items: Item[] }) {
const sorted = [...items].sort((a, b) => a.id - b.id);
return <List items={sorted} />;
}
アンチパターン: render 中の setState
function Bad({ value }: { value: number }) {
const [doubled, setDoubled] = useState(0);
setDoubled(value * 2); // ループする
return <span>{doubled}</span>;
}
修正版
function Good({ value }: { value: number }) {
const doubled = value * 2;
return <span>{doubled}</span>;
}
state を計算で置き換えられる場合は、state を持たないのが正解です。
CI への組み込み
name: lint
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- run: npx eslint . --max-warnings 0
eslint-plugin-react-compiler を有効にした状態で --max-warnings 0 を指定し、Compiler が安全に動かせる状態を CI で保証します。
チームへの導入アプローチ
- ペアプロやモブプロで「もう useMemo を書かない」体験を共有
- コードレビューのチェック項目から「依存配列の漏れ」を外す
- 新人向けドキュメントに Rules of React と React Compiler を必須項目として追加
- 既存コードのリファクタは “ボーイスカウト原則” で触ったついでにクリーンアップ