React Compilerを既存のプロジェクトに少しずつ入れる方法

​​React​Babel,ESLint,React Compiler,TypeScript,Vite

React Compiler は、既存のプロジェクト全体に一度に入れなくても、一部から試して広げていけます。公式の 段階的な導入のガイド には、フォルダを指定する方法、"use memo" を付けた関数だけを変換する方法、実行時のフラグで切り替える方法(gating)の3つが載っています。

筆者は検証環境で、既存のプロジェクトに見立てた小さなアプリを Vite 8 でビルドし、3つの方法を順に試しました。どの関数がコンパイルされたかは、すべてコンパイラの logger の出力で確かめています。Babel の設定ファイルでフォルダを指定するときには、公式の例どおりに書くとコンパイラが働かない落とし穴がありました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。babel-plugin-react-compiler 1.0.0、React 19.3.1、Vite 8.3.5、@vitejs/plugin-react 6.1.2、@rolldown/plugin-babel 0.2.4、eslint-plugin-react-hooks 7.1.1 を使いました。Babel の設定ファイルの確認には、@babel/core 8.0.7 と 7.29.7 を使っています。

検証に使ったアプリ

コンポーネントとフックを合わせて7つの関数があるアプリです。src/features/cart にカートの部品が3つ、src/features/profile に1つ、src/legacy に古い部品が2つあり、src の直下に全体をまとめる App があります。

このうち legacy/OrderTable.tsx には、わざとルール違反を入れました。描画の途中で props のオブジェクトを書き換えています。

tsx
export function OrderTable({ rows, options }: { rows: Row[]; options: Options }) {
  options.renderedCount = (options.renderedCount ?? 0) + 1; // レンダー中に props を書き換える(ルール違反)
  return (
    <table>
      <tbody>
        {rows.map((r) => <tr key={r.id}><td>{r.amount}</td></tr>)}
      </tbody>
    </table>
  );
}

どの関数がコンパイルされたかを見るために、コンパイラの logger オプション を使いました。CompileSuccess(コンパイルできた)、CompileError(エラーでスキップした)、CompileSkip(それ以外の理由でスキップした)の3種類だけを1行ずつ出しています(イベントの種類はほかにもあります)。

ts
import { relative } from 'node:path';
import type { Logger } from 'babel-plugin-react-compiler';

// どの関数がコンパイルされたか(されなかったか)を1行ずつ出す
export const logger: Logger = {
  logEvent(filename, event) {
    if (event.kind === 'CompileSuccess' || event.kind === 'CompileError' || event.kind === 'CompileSkip') {
      const name = 'fnName' in event ? (event.fnName ?? '') : '';
      console.log(`[compiler] ${event.kind} ${relative(process.cwd(), filename ?? '')} ${name}`);
    }
  },
};

導入がどこまで進んだかを数えるときにも、このログが使えます。

入れる前に、ESLintでスキップされる場所を探す

まず、コンパイラを入れる前に eslint-plugin-react-hooks の推奨設定でリンターをかけました。設定は次のとおりです。TypeScript のファイルを読むために、パーサーには typescript-eslint のものを指定しています。

js
import reactHooks from 'eslint-plugin-react-hooks';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';

export default defineConfig([
  { files: ['src/**/*.{ts,tsx}'], languageOptions: { parser: tseslint.parser } },
  reactHooks.configs.flat.recommended,
]);

OrderTable の書き換えが、react-hooks/immutability のエラーとして見つかりました。次は出力の先頭の部分です。

text
./src/legacy/OrderTable.tsx
  5:3  error  Error: This value cannot be modified
Modifying component props or hook arguments is not allowed. Consider using a local variable instead.

インストールのページの、ESLint との組み合わせについての説明 によると、このルールがエラーを出したコンポーネントやフックは、コンパイラが最適化をスキップします。ほかの部分の最適化は続くので、違反をすぐに全部直す必要はないとされています。どこがスキップされるかを先に知っておくと、どのフォルダから入れるかを決めやすくなります。

フォルダごとに広げる(Vite)

Vite では、インストールのページの Vite の節 にあるとおり、@rolldown/plugin-babel と reactCompilerPreset でコンパイラを入れます。@rolldown/plugin-babel には include オプションがあり、変換するファイルを絞れます。最初は src/features/cart だけにしました。

ts
import { defineConfig } from 'vite';
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
import babel from '@rolldown/plugin-babel';
import { logger } from './compiler-logger.ts';

export default defineConfig({
  plugins: [
    react(),
    babel({
      include: [/\/src\/features\/cart\//], // まずはこのフォルダだけ
      presets: [reactCompilerPreset({ logger })],
    }),
  ],
});

ビルドすると、カートの3つだけがコンパイルされました。

text
[compiler] CompileSuccess src/features/cart/CartSummary.tsx CartSummary
[compiler] CompileSuccess src/features/cart/PriceRow.tsx PriceRow
[compiler] CompileSuccess src/features/cart/useCartTotal.ts useCartTotal

出来上がったバンドルを見ると(読みやすいように --minify false でビルドしました)、CartSummary はコンパイラのランタイム(import_compiler_runtime.c)でキャッシュを用意していました。対象外の ProfileCard には、キャッシュのためのコードが入っていません。

text
function CartSummary(t0) {
	const $ = (0, import_compiler_runtime.c)(8);
	const { items } = t0;
	const [note, setNote] = (0, import_react.useState)("");
--
function ProfileCard({ name }) {
	return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
		className: "card",
		children: name

次に include を [/\/src\/features\//] に広げると、ProfileCard も加わって4つになりました。最後に include を外してプロジェクト全体にすると、7つのうち6つがコンパイルされました。OrderTable だけは CompileError でスキップされましたが、ビルドは成功しています。

text
[compiler] CompileSuccess src/App.tsx App
[compiler] CompileSuccess src/features/cart/CartSummary.tsx CartSummary
[compiler] CompileSuccess src/legacy/LegacyBanner.tsx LegacyBanner
[compiler] CompileError src/legacy/OrderTable.tsx 
[compiler] CompileSuccess src/features/profile/ProfileCard.tsx ProfileCard
[compiler] CompileSuccess src/features/cart/useCartTotal.ts useCartTotal
[compiler] CompileSuccess src/features/cart/PriceRow.tsx PriceRow

各段階の結果を図にまとめました。③は、このあとの「1つだけ外す:"use no memo"」の節で、LegacyBanner に "use no memo" を付けてビルドした結果です。

3つの段階で、7つの関数のどれがコンパイルされたかを示す図。①src/features/cart だけのときは CartSummary・PriceRow・useCartTotal の3つ。②src/features の下をすべてにすると ProfileCard が加わって4つ。③全体にして LegacyBanner に &quot;use no memo&quot; を付けると、App も加わって5つ。LegacyBanner は外され、OrderTable はルール違反でスキップされた
段階ごとに、コンパイルされた関数(水色)が増えていく様子。OrderTable はどの段階でもコンパイルされない

Babelの設定ファイルでは、公式の例のままだとコンパイラが働かなかった

Vite を使わず、Babel の設定ファイルでコンパイラを入れているプロジェクトもあります。公式のガイドにある、フォルダごとに入れる設定の例 では、Babel の overrides の test に './src/modern/**/*.{js,jsx,ts,tsx}' と書かれています。

検証用のアプリで、同じ形の './src/features/**/*.{js,jsx,ts,tsx}' を test に書いて試しました。設定ファイルは次のとおりです。

js
// babel.config.cjs(logger の定義は省略。test の値は検証ごとに書き換えた)
module.exports = {
  presets: [
    '@babel/preset-typescript',
    ['@babel/preset-react', { runtime: 'automatic' }],
  ],
  overrides: [
    {
      test: './src/features/**/*.{js,jsx,ts,tsx}',
      plugins: [['babel-plugin-react-compiler', { logger }]],
    },
  ],
};

この設定で src の下の11ファイルを変換するスクリプト(babel-all.mjs)を実行しました。Babel 8.0.7 と 7.29.7 のどちらでも、コンパイラは1つの関数も処理しませんでした。エラーも警告も出ず、11ファイルの変換は最後まで終わりました。

text
--- test: './src/features/**/*.{js,jsx,ts,tsx}'(Babel 8)
$ node babel-all.mjs
変換したファイル:11個

Babel のオプションのページにある、ファイルのパスの指定方法の説明 によると、文字列での指定は、完全なグロブのパターンには対応していません。使えるのは **、*、*.ext だけです。波かっこ {…} は、この中に含まれません。検証でも、波かっこを使ったパターンには、どのファイルも当てはまりませんでした。次の2つの書き方では、どちらの Babel でも src/features の下の4つの関数がコンパイルされました。

js
// フォルダを指定する(その下のファイルがすべて当てはまる)
test: './src/features',

// 拡張子ごとに分けて並べる
test: ['./src/features/**/*.ts', './src/features/**/*.tsx'],

'./src/features/**/*.tsx' だけにすると、.ts で書いたフック useCartTotal が対象から外れました。

注意

test の指定が何にも当てはまらなくても、Babel はエラーを出しません。コンパイラを入れたつもりで、実際には何も変わっていないことがあります。設定したら、logger などで、コンパイルされた関数があるかを確かめてください。

なお、@rolldown/plugin-babel の README によると、このプラグインは babel.config.js などの設定ファイルを読み込みません。Vite でこのプラグインを使う場合は、前の節のように、Babel の設定をプラグインのオプションで渡します。include にも、Babel と同じ形式のパターンを書きます。

1つだけ外す:"use no memo"

広げた範囲の中に、コンパイル後の動きが心配なコンポーネントがあれば、関数の先頭に "use no memo" を書いて外せます。検証では LegacyBanner に付けました。

tsx
export function LegacyBanner({ text }: { text: string }) {
  "use no memo"; // TODO: コンパイル後の表示を確かめたら外す
  return <p className="banner">{text}</p>;
}

全体を対象にしてビルドし直すと、LegacyBanner は CompileSkip になりました。

text
[compiler] CompileSkip src/legacy/LegacyBanner.tsx 

"use no memo" のリファレンス では、これを一時的な逃げ道として扱い、外した理由をコメントに残すよう勧めています。ファイルの先頭に書いて、そのファイル全体を外す使い方も載っています(筆者はファイル全体の指定は試していません)。

panicThresholdは'none'のままにしておく

OrderTable のようなルール違反があっても、ビルドが通ったのは panicThreshold の既定値が 'none' だからです。試しに 'all_errors' にすると、OrderTable のところでビルドが失敗しました。次は、エラーの出力の一部です。

text
✗ Build failed in 313ms
error during build:
Build failed with 1 error:
[plugin @rolldown/plugin-babel] ./src/legacy/OrderTable.tsx
RolldownError: [BabelError] Found 1 error:
Error: This value cannot be modified

panicThreshold のリファレンス は、本番のビルドでは必ず 'none' を使うよう書いています。厳しい値は、開発中に問題を探すときだけ一時的に使うものとされています。少しずつ入れる途中では、違反のある部分はコンパイラにスキップさせ、ESLint の結果を見ながら直していくのが安全だと筆者は考えます。

関数を1つずつ選ぶ(compilationMode の 'annotation')

フォルダより細かく、関数を1つずつ選んで試したいときは、compilationMode のリファレンス にある 'annotation' を使います。このモードでは、"use memo" を付けた関数だけがコンパイルされます。

ts
babel({ presets: [reactCompilerPreset({ compilationMode: 'annotation', logger })] })
tsx
export function ProfileCard({ name }: { name: string }) {
  "use memo";
  return <div className="card">{name}</div>;
}

ビルドすると、コンパイルされたのは ProfileCard だけでした。

text
[compiler] CompileSuccess src/features/profile/ProfileCard.tsx ProfileCard

公式のガイドの、"use memo" の使い方の説明 によると、このモードでは、最適化したいコンポーネントとカスタムフックのすべてに "use memo" を書く必要があります。あとから作るコンポーネントにも、忘れずに書きます。

実行時に切り替える:gating

gating オプションを使うと、コンパイル後の版と元の版の両方をバンドルに入れ、実行時に呼ぶ関数の戻り値でどちらを使うかを決められます。検証では、URL に ?compiler=on が付いているときだけ、コンパイル後の版を使うようにしました。

ts
// src/compilerFlags.ts
// URL に ?compiler=on が付いているときだけ、コンパイル後の版を使う
export function isCompilerEnabled(): boolean {
  return new URLSearchParams(window.location.search).get('compiler') === 'on';
}
ts
babel({
  presets: [
    reactCompilerPreset({
      gating: { source: '/src/compilerFlags.ts', importSpecifierName: 'isCompilerEnabled' },
      logger,
    }),
  ],
}),

source を /src/… の形で書いて、Vite のビルドで動くことを確かめました(ほかのビルドツールでは試していません)。gating のリファレンス の例は、'./src/utils/feature-flags' やパッケージの名前で書かれています。

ビルドしたバンドルでは、コンパイルできた6つの関数が、それぞれ isCompilerEnabled() で切り替わる形になっていました。次は ProfileCard の部分です(途中を省略しています)。

text
var ProfileCard = isCompilerEnabled() ? function ProfileCard(t0) {
	const $ = (0, import_compiler_runtime.c)(2);
	...
} : function ProfileCard({ name }) {
	return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {

実際に切り替わるかを確かめるため、ビルドしたアプリを vite preview で開き、カートのメモ欄に Playwright で3文字入力しました。カートの合計を表示する子コンポーネント PriceRow が描画された回数は、次のとおりです。

text
URL=/  input="abc"  PriceRow の描画回数=4
URL=/?compiler=on  input="abc"  PriceRow の描画回数=1

フラグがないときは、最初の1回と入力の3回で4回描画されました。?compiler=on のときは、最初の1回だけでした。コンパイル後の CartSummary は、メモ欄が変わっても合計が同じなので、PriceRow を描画し直さなかったことになります。

gating のリファレンス によると、フラグの関数はモジュールを読み込んだときに1回だけ評価されます。そのため、ページを開いたあとでは切り替わりません。また、2つの版が両方ともバンドルに入るため、バンドルは大きくなるとされています(筆者はサイズを比べていません)。公式のガイドの gating の節 では、A/B テストや、利用者のグループごとに少しずつ広げたいときに役立つとされています。

どの順で進めるか

検証の結果と公式のガイドをふまえて、筆者は、既存のプロジェクトには次の順で入れるのがよいと考えています。

  1. ESLint の推奨設定で、スキップされる場所を先に知る
  2. 違反のないフォルダから include(Babel なら overrides の test)で入れ、logger でコンパイルされた関数を数える
  3. 範囲を広げ、心配な関数には一時的に "use no memo" を付ける
  4. 最後に include を外して、全体に入れる

関数を1つずつ選びたいときは 'annotation' モード、本番で効果を比べたいときは gating を使います。

ポイント

少しずつ入れても、panicThreshold が 'none' なら、ルール違反のある部分はスキップされるだけでビルドは止まりません。ただし、フォルダの指定が間違っていてもエラーにならないので、広げるたびに logger でコンパイルされた関数の数を確かめましょう。

​Posted by 86