React Compiler とは、ビルドのときにコンポーネントとフックを解析し、useMemo・useCallback・React.memo に相当するメモ化のコードを自動で入れてくれるツールです。 導入は babel-plugin-react-compiler を入れて設定に1〜数行足すだけで、コンポーネントの書き方を変える必要はありません。

一方で、React のルールから外れた書き方(props の書き換え、レンダー中の ref の読み書きなど)を含むコンポーネントは、コンパイラが何も言わずに最適化を見送ります。この記事では、Vite・Next.js・Babel 単体の設定を並べ、同じコンポーネントの変換前後のコードを実際に出力しました。さらに、最適化されないパターンを13種類用意し、コンパイラのログと ESLint の警告文を1つずつ対応させています。「入れたのに効いていない気がする」ときに、原因を探す手がかりとして使える内容です。

確認環境:Ubuntu 24.04 / Node.js 22.22.2 / babel-plugin-react-compiler 1.0.0 / React 19.3.0(target の確認は React 18.3.1 と react-compiler-runtime 1.0.0)/ @babel/core 8.0.6 / Vite 8.3.2・@vitejs/plugin-react 6.1.1・@rolldown/plugin-babel 0.2.4 / Next.js 16.3.8 / ESLint 10.12.0・eslint-plugin-react-hooks 7.1.1。

React Compiler の早見表

やりたいこと 書き方 補足
インストールする npm install -D babel-plugin-react-compiler@latest ビルド時だけ使う
Babel 単体で使う plugins の先頭に "babel-plugin-react-compiler" ほかのプラグインより先に動かす
Vite で使う @rolldown/plugin-babel に reactCompilerPreset() を渡す @vitejs/plugin-react 6 系の書き方
Next.js で使う next.config に reactCompiler: true babel-plugin-react-compiler も入れる
効いているか確かめる 変換後のコードに _c(数) があるか見る React DevTools では Memo ✨ のバッジ
1つだけ対象から外す 関数の先頭に "use no memo" 一時的な退避用
指定したものだけ対象にする compilationMode: "annotation" と "use memo" 少しずつ導入するとき
React 17・18 で使う target: "18" と react-compiler-runtime 既定は "19"
ルール違反を見つける eslint-plugin-react-hooks の recommended-latest コンパイラ用のルールが入っている

自動メモ化が効かない主な原因と、ESLint のルール名の対応です(詳しい出力は後の章に載せています)。

原因 コンパイラのログ ESLint のルール
props を書き換えている This value cannot be modified react-hooks/immutability
レンダー中に ref を読み書きしている Cannot access refs during render react-hooks/refs
フックを条件つきで呼んでいる Hooks must always be called in a consistent order react-hooks/rules-of-hooks
コンポーネントの外の変数に再代入している Cannot reassign variables declared outside of the component/hook react-hooks/globals
レンダー中に setState を呼んでいる Calling setState during render may trigger an infinite loop react-hooks/set-state-in-render
手書きの useMemo・useCallback の依存配列が合っていない Existing memoization could not be preserved react-hooks/exhaustive-deps

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React Compilerの使い方と導入手順|自動メモ化が効かない原因」

React Compiler がすること:変換前後のコードを見る

まず、コンパイラが実際に何をしているかを見ます。Babel のコマンドで、1つのコンポーネントを変換します。

npm install react react-dom
npm install -D babel-plugin-react-compiler@latest @babel/core @babel/cli
{
  "plugins": ["babel-plugin-react-compiler"],
  "parserOpts": { "plugins": ["jsx"] }
}

上の内容を babel.config.json として保存します。parserOpts の指定は、JSX を読めるようにするためのものです(JSX を残したまま出力して、コンパイラの変換だけを見やすくしています)。

変換前

// src/ItemList.jsx
export function ItemList({ items, keyword }) {
  const visible = items.filter((i) => i.title.includes(keyword));
  return (
    <ul>
      {visible.map((i) => (
        <li key={i.id}>{i.title}</li>
      ))}
    </ul>
  );
}

変換後

npx babel src/ItemList.jsx
import { c as _c } from "react/compiler-runtime";
export function ItemList(t0) {
  const $ = _c(7);
  const {
    items,
    keyword
  } = t0;
  let t1;
  if ($[0] !== items || $[1] !== keyword) {
    let t2;
    if ($[3] !== keyword) {
      t2 = i => i.title.includes(keyword);
      $[3] = keyword;
      $[4] = t2;
    } else {
      t2 = $[4];
    }
    const visible = items.filter(t2);
    t1 = visible.map(_temp);
    $[0] = items;
    $[1] = keyword;
    $[2] = t1;
  } else {
    t1 = $[2];
  }
  let t2;
  if ($[5] !== t1) {
    t2 = <ul>{t1}</ul>;
    $[5] = t1;
    $[6] = t2;
  } else {
    t2 = $[6];
  }
  return t2;
}
function _temp(i_0) {
  return <li key={i_0.id}>{i_0.title}</li>;
}

読み方のポイントは次のとおりです。

変換後のコード 意味
import { c as _c } from "react/compiler-runtime" メモ化用の関数を React 本体から読み込む
const $ = _c(7) 値を覚えておく場所(キャッシュ配列)を7つ分用意する
if ($[0] !== items || $[1] !== keyword) 前回と入力が変わったときだけ計算し直す
$[2] = t1 と t1 = $[2] 結果を覚えておき、入力が同じなら前回の結果を使う
function _temp(...) 外の値を使わない関数は、コンポーネントの外へ出して使い回す

items と keyword が前回と同じなら、filter も map も実行されず、前回の結果がそのまま返ります。 手で書くなら useMemo が必要な処理を、コンパイラが代わりに入れてくれています。JSX の <ul> も、中身が変わらなければ同じものが返るので、子コンポーネントの再レンダーも抑えられます。

再レンダーの回数で効果を確かめる

親の state が変わったとき、子がどれだけ再レンダーされるかを比べます。

// src/App.jsx
import { useState } from "react";

function Heavy({ items }) {
  console.log("render: Heavy");
  return <p>{items.length} 件</p>;
}

export function App() {
  console.log("render: App");
  const [n, setN] = useState(0);
  const items = [{ id: 1 }, { id: 2 }];
  return (
    <div>
      <button id="plus" onClick={() => setN(n + 1)}>{n}</button>
      <Heavy items={items} />
    </div>
  );
}

このコンポーネントを、コンパイラなし・ありの2通りで変換し、jsdom(30.1.1)上で表示してボタンを3回押したときの、レンダー回数(console.log の回数)です。

Compiler なし: App=4回 Heavy=4回 ボタンの表示: 3
Compiler あり: App=4回 Heavy=1回 ボタンの表示: 3

コンパイラなしでは、App が再レンダーされるたびに items の配列が作り直され、Heavy も毎回レンダーされます。コンパイラありでは、Heavy のレンダーは最初の1回だけでした。React.memo も useMemo も書いていないコードのままで、この結果になります。

導入手順:Babel 単体・Vite・Next.js の設定

Babel 単体の設定

// babel.config.js
module.exports = {
  plugins: [
    "babel-plugin-react-compiler", // 最初に置く
    // ほかのプラグイン
  ],
};

公式ドキュメントは、React Compiler を Babel のプラグインの中で最初に動かすよう案内しています。ほかのプラグインが変換する前の、元のコードを解析する必要があるためです。オプションを渡すときは、配列の形にします。

// babel.config.js
module.exports = {
  plugins: [
    ["babel-plugin-react-compiler", { compilationMode: "infer", target: "19" }],
  ],
};

Vite の設定

@vitejs/plugin-react 6.1.1 では、@rolldown/plugin-babel と reactCompilerPreset を組み合わせます。

npm install -D babel-plugin-react-compiler@latest @rolldown/plugin-babel
// vite.config.js
import { defineConfig } from "vite";
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
import babel from "@rolldown/plugin-babel";

export default defineConfig({
  plugins: [
    react(),
    babel({
      presets: [reactCompilerPreset()],
    }),
  ],
});

この設定で、前の章の App.jsx を含むアプリを npx vite build し、成果物を検索しました。

grep -c 'memo_cache_sentinel' dist/assets/*.js
2

memo_cache_sentinel は、キャッシュ配列の「まだ値が入っていない」ことを表す印です。成果物の App の部分は、次のように変換されていました(抜粋)。

console.log(`render: App`);let[t,n]=(0,h.useState)(0),r;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(r=[{id:1},{id:2}],e[0]=r):r=e[0];

babel(...) の部分を外して react() だけにしてビルドし直すと、同じ場所はキャッシュ配列のないコードになりました(抜粋)。

console.log(`render: App`);let[e,t]=(0,f.useState)(0);return(0,p.jsxs)(`div`,{children:[(0,p.jsx)(`button`,{id:`plus`,onClick:()=>t(e+1),children:e}),

オプションを渡したいときは、プラグインを直接指定する書き方も使えます。次の設定でビルドが通ることを確かめています。

// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import babel from "@rolldown/plugin-babel";

export default defineConfig({
  plugins: [
    react(),
    babel({
      plugins: [["babel-plugin-react-compiler", { compilationMode: "annotation" }]],
    }),
  ],
});

公式ドキュメントには、以前の版の Vite 向けとして、react({ babel: { plugins: ["babel-plugin-react-compiler"] } }) のように react() の babel オプションへ渡す書き方も載っています。使っている @vitejs/plugin-react の版に合わせて選びます。

Next.js の設定

Next.js 16.3.8 では、babel-plugin-react-compiler を入れて、next.config に1行足します。

npm install -D babel-plugin-react-compiler
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactCompiler: true,
};

export default nextConfig;

App Router の最小構成(app/layout.jsx と、App.jsx と同じ内容のクライアントコンポーネントを置いた app/page.jsx)で npx next build を実行し、ページのコードが入ったファイルを検索しました。

next.config の設定 ページのファイル内の memo_cache_sentinel
reactCompiler: true 1件
reactCompiler: false 0件

Next.js の公式ドキュメントによると、Next.js は JSX やフックを含むファイルにだけコンパイラを適用する仕組みを持っており、すべてのファイルに Babel をかける場合よりビルドが速くなります。指定したものだけを対象にしたいときは、reactCompiler: { compilationMode: "annotation" } のようにオブジェクトで渡す書き方が、同じページに載っています。

設定の比較表

ビルドツール 追加で入れるもの 設定する場所 今回の確認
Babel 単体 babel-plugin-react-compiler babel.config の plugins の先頭 babel コマンドで変換
Vite 8 上に加えて @rolldown/plugin-babel vite.config の plugins vite build の成果物を検索
Next.js 16 babel-plugin-react-compiler next.config の reactCompiler next build の成果物を検索

公式ドキュメントには、このほかに React Router、Metro(React Native)、Rspack、Rsbuild、Expo、webpack 用のローダーへの案内があります。

効いているかを確かめる3つの方法

方法 見るところ 向いている場面
変換後のコードを見る _c(数) や memo_cache_sentinel があるか ビルド設定が正しいかの確認
React DevTools コンポーネント名の横の Memo ✨ バッジ 画面を見ながら、どのコンポーネントが対象か確かめる
コンパイラの logger CompileSuccess・CompileError・CompileSkip のイベント 対象外になった理由を一覧で知りたいとき

React DevTools のバッジは、公式ドキュメントで案内されている確認方法です。開発モードでアプリを開き、DevTools のコンポーネント一覧で ✨ の印が付いているものが、コンパイラによって最適化されたコンポーネントです。

3つ目の logger は、次の章で使います。

自動メモ化が効かないパターンをログと ESLint で確かめる

コンパイラは、安全に最適化できないと判断したコンポーネントをエラーにせず、そのまま通します。ビルドは止まらず、画面も動くので、「効いていない」ことに気づきにくいのが特徴です。

そこで、13種類のコンポーネントを用意して、1つずつコンパイラに通しました。オプションの logger に関数を渡すと、コンパイルの結果がイベントとして届きます。

// check.mjs
import { transformSync } from "@babel/core";
import fs from "node:fs";

for (const file of fs.readdirSync("cases").sort()) {
  const events = [];
  const logger = { logEvent(_filename, event) { events.push(event); } };
  const { code } = transformSync(fs.readFileSync("cases/" + file, "utf8"), {
    filename: "cases/" + file,
    configFile: false,
    parserOpts: { plugins: ["jsx"] },
    plugins: [["babel-plugin-react-compiler", { logger }]],
  });
  console.log(`${file}: 自動メモ化=${code.includes("_c(") ? "あり" : "なし"}`);
  for (const event of events) {
    const reason = event.kind === "CompileError" ? ": " + (event.detail?.options?.reason ?? event.detail?.reason) : "";
    console.log(`   ${event.kind}${reason}`);
  }
}

用意したコンポーネントのうち、最適化されなかった9種類は次のとおりです。

// cases/01-props-mutate.jsx
export function Badge(props) {
  props.label = props.label.trim();
  return <span>{props.label}</span>;
}
// cases/02-ref-read.jsx
import { useRef } from "react";

export function Counter() {
  const count = useRef(0);
  return <p>{count.current}</p>;
}
// cases/03-ref-write.jsx
import { useRef } from "react";

export function RenderCount() {
  const renders = useRef(0);
  renders.current += 1;
  return <p>表示中</p>;
}
// cases/04-conditional-hook.jsx
import { useState } from "react";

export function Panel({ open }) {
  if (open) {
    const [text, setText] = useState("");
    return <input value={text} onChange={(e) => setText(e.target.value)} />;
  }
  return null;
}
// cases/05-global-reassign.jsx
let renderTotal = 0;

export function Header() {
  renderTotal += 1;
  return <h1>備品一覧</h1>;
}
// cases/06-setstate-in-render.jsx
import { useState } from "react";

export function Loader({ items }) {
  const [list, setList] = useState([]);
  setList(items);
  return <p>{list.length} 件</p>;
}
// cases/07-try-finally.jsx
export function Parsed({ json }) {
  let data = null;
  try {
    data = JSON.parse(json);
  } finally {
    console.log("parsed");
  }
  return <p>{data?.title}</p>;
}
// cases/08-memo-deps-mismatch.jsx
import { useCallback } from "react";

export function Search({ filter, onSearch }) {
  const run = useCallback(() => onSearch(filter.keyword), [filter.keyword.length]);
  return <button onClick={run}>検索</button>;
}
// cases/09-use-no-memo.jsx
export function Legacy({ name }) {
  "use no memo";
  return <p>{name}</p>;
}

node check.mjs の出力です(10〜13 は後で説明します)。

01-props-mutate.jsx: 自動メモ化=なし
   CompileError: This value cannot be modified
02-ref-read.jsx: 自動メモ化=なし
   CompileError: Cannot access refs during render
03-ref-write.jsx: 自動メモ化=なし
   CompileError: Cannot access refs during render
   CompileError: Cannot access refs during render
04-conditional-hook.jsx: 自動メモ化=なし
   CompileError: Hooks must always be called in a consistent order, and may not be called conditionally. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning)
05-global-reassign.jsx: 自動メモ化=なし
   CompileError: Cannot reassign variables declared outside of the component/hook
06-setstate-in-render.jsx: 自動メモ化=なし
   CompileError: Calling setState during render may trigger an infinite loop
07-try-finally.jsx: 自動メモ化=なし
   CompileError: (BuildHIR::lowerStatement) Handle TryStatement without a catch clause
08-memo-deps-mismatch.jsx: 自動メモ化=なし
   CompileError: Existing memoization could not be preserved
   CompileError: Existing memoization could not be preserved
09-use-no-memo.jsx: 自動メモ化=なし
   CompileSkip
10-impure.jsx: 自動メモ化=あり
   CompileSuccess
11-setstate-in-effect.jsx: 自動メモ化=あり
   CompileSuccess
12-memo-missing-dep.jsx: 自動メモ化=あり
   CompileSuccess
13-state-mutate.jsx: 自動メモ化=あり
   CompileSuccess

ログには CompileError と出ていますが、ビルドは止まりません。そのコンポーネントが変換されずに出力されるだけです。

ESLint の設定と警告文

同じ13ファイルを ESLint にかけます。コンパイラ用のルールは、eslint-plugin-react-hooks の recommended-latest に入っています。

npm install -D eslint eslint-plugin-react-hooks@latest
// eslint.config.js
import reactHooks from "eslint-plugin-react-hooks";

export default [
  {
    files: ["**/*.{js,jsx}"],
    languageOptions: {
      parserOptions: { ecmaFeatures: { jsx: true } },
    },
  },
  reactHooks.configs.flat["recommended-latest"],
];

npx eslint cases を実行すると、たとえば 01 のファイルには次のように表示されます。

cases/01-props-mutate.jsx
  2:3  error  Error: This value cannot be modified

Modifying component props or hook arguments is not allowed. Consider using a local variable instead.

  1 | export function Badge(props) {
> 2 |   props.label = props.label.trim();
    |   ^^^^^ value cannot be modified
  3 |   return <span>{props.label}</span>;
  4 | }
  5 |  react-hooks/immutability

どの行の、どの書き方が理由なのかが、直し方のヒントつきで表示されます。 13ファイルすべての結果(ルール名とメッセージの1行目)は、次の一覧表にまとめました。

最適化されないパターンの一覧表

コンパイラのログと ESLint の結果を、1つの表にまとめます。

番号 パターン 自動メモ化 コンパイラのログ ESLint のルールと警告文
01 props の書き換え なし This value cannot be modified immutability:This value cannot be modified
02 レンダー中の ref の読み取り なし Cannot access refs during render refs:Cannot access refs during render
03 レンダー中の ref への書き込み なし Cannot access refs during render refs:Cannot access refs during render
04 フックの条件呼び出し なし Hooks must always be called in a consistent order, and may not be called conditionally. rules-of-hooks:React Hook "useState" is called conditionally.
05 外の変数への再代入 なし Cannot reassign variables declared outside of the component/hook globals:Cannot reassign variables declared outside of the component/hook
06 レンダー中の setState なし Calling setState during render may trigger an infinite loop set-state-in-render:Cannot call setState during render
07 catch のない try〜finally なし Handle TryStatement without a catch clause 指摘なし
08 手書きメモ化の依存配列がずれている なし Existing memoization could not be preserved exhaustive-deps(warning):useCallback has missing dependencies
09 "use no memo" を付けている なし CompileSkip 指摘なし

01〜06 と 08 は、ESLint を入れておけばエディタ上で気づけます。 07 と 09 は ESLint には出ないので、logger の出力や変換後のコードで確かめます。

自動メモ化はされるが、ESLint が指摘するパターン

残りの4種類は、コンパイルは通りました(自動メモ化=あり)。

// cases/10-impure.jsx
export function Stamp() {
  const now = Date.now();
  return <p>{now}</p>;
}
// cases/11-setstate-in-effect.jsx
import { useEffect, useState } from "react";

export function Sync({ value }) {
  const [copy, setCopy] = useState(value);
  useEffect(() => {
    setCopy(value);
  }, [value]);
  return <p>{copy}</p>;
}
// cases/12-memo-missing-dep.jsx
import { useMemo } from "react";

export function Total({ items, rate }) {
  const total = useMemo(
    () => items.reduce((sum, i) => sum + i.price, 0) * rate,
    [items]
  );
  return <p>{total}</p>;
}
// cases/13-state-mutate.jsx
import { useState } from "react";

export function Tags() {
  const [tags, setTags] = useState(["新着"]);
  tags.push("在庫あり");
  return <p>{tags.join(", ")}</p>;
}
番号 パターン 自動メモ化 ESLint のルールと警告文
10 レンダー中の Date.now() あり purity:Cannot call impure function during render
11 エフェクトの中ですぐ setState あり set-state-in-effect:Calling setState synchronously within an effect can trigger cascading renders
12 useMemo の依存配列に不足がある あり exhaustive-deps(warning):useMemo has a missing dependency: 'rate'
13 state の配列を push で書き換え あり 指摘なし

10〜12 は、コンパイルは通るものの、ESLint が「直したほうがよい書き方」として知らせてくれます。10 の変換後のコードは次のとおりで、Date.now() は最初の1回しか実行されず、以降は覚えておいた結果が返ります。

export function Stamp() {
  const $ = _c(1);
  let t0;
  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    const now = Date.now();
    t0 = <p>{now}</p>;
    $[0] = t0;
  } else {
    t0 = $[0];
  }
  return t0;
}

レンダーのたびに実行される前提のコードは、メモ化によって値が更新されなくなることがあります。ESLint の指摘は、コンパイラを入れる前に直しておくと安心です。

13 は、今回の版ではコンパイラも ESLint も何も出しませんでした。state を直接書き換える書き方は、ツールで見つからない場合もあるので、イベントの処理の中で setTags([...tags, "在庫あり"]) のように新しい配列を渡す書き方にそろえておきます。

直した後のコードで、最適化されることを確かめる

5つのパターンを直し、同じスクリプトに通しました。

// fixed/01-props-local.jsx
export function Badge(props) {
  const label = props.label.trim();
  return <span>{label}</span>;
}
// fixed/02-state-instead-of-ref.jsx
import { useState } from "react";

export function Counter() {
  const [count, setCount] = useState(0);
  return <p onClick={() => setCount(count + 1)}>{count}</p>;
}
// fixed/04-hook-at-top.jsx
import { useState } from "react";

export function Panel({ open }) {
  const [text, setText] = useState("");
  if (!open) {
    return null;
  }
  return <input value={text} onChange={(e) => setText(e.target.value)} />;
}
// fixed/07-try-catch.jsx
export function Parsed({ json }) {
  let data = null;
  try {
    data = JSON.parse(json);
  } catch {
    data = null;
  }
  return <p>{data?.title}</p>;
}
// fixed/08-memo-deps-match.jsx
import { useCallback } from "react";

export function Search({ filter, onSearch }) {
  const run = useCallback(() => onSearch(filter.keyword), [filter.keyword, onSearch]);
  return <button onClick={run}>検索</button>;
}
01-props-local.jsx: 自動メモ化=あり
   CompileSuccess
02-state-instead-of-ref.jsx: 自動メモ化=あり
   CompileSuccess
04-hook-at-top.jsx: 自動メモ化=あり
   CompileSuccess
07-try-catch.jsx: 自動メモ化=あり
   CompileSuccess
08-memo-deps-match.jsx: 自動メモ化=あり
   CompileSuccess

ESLint の指摘も、5ファイルとも0件になりました。

直す前 直した後
props のプロパティに代入する ローカル変数に受けて使う
画面に出す値を ref で持つ state で持つ(ref はイベントやエフェクトの中で使う)
if の中でフックを呼ぶ フックを先頭で呼び、その後で分岐する
try〜finally だけ catch を付ける
依存配列が実際に使う値とずれている 使っている値をそのまま依存配列に書く

compilationMode の infer・annotation・all の違い

compilationMode は、どの関数をコンパイルの対象にするかを決めるオプションです。公式ドキュメントに載っている値は infer・annotation・syntax・all の4つで、既定は infer です。syntax は Flow の専用構文向けなので、ここでは残りの3つを比べます。

5種類の関数を1ファイルに置き、モードを変えて、どれがコンパイルされるかを出力しました。

// src/modes.jsx
import { useState } from "react";

// 1. コンポーネント(大文字始まり+JSX)
export function Button(props) {
  return <button>{props.label}</button>;
}

// 2. フック(use始まり+フック呼び出し)
export function useCounter() {
  const [count, setCount] = useState(0);
  return [count, setCount];
}

// 3. ふつうの関数
export function calcTotal(items) {
  return items.map((i) => i.price * 2);
}

// 4. ふつうの関数に "use memo" を付けたもの
export function buildRows(items) {
  "use memo";
  return items.map((i) => ({ id: i.id, label: i.title }));
}

// 5. コンポーネントに "use no memo" を付けたもの
export function Legacy(props) {
  "use no memo";
  return <p>{props.name}</p>;
}

check.mjs と同じ要領で、compilationMode を infer・annotation・all に変えながら変換し、logger に届いた CompileSuccess の関数名と CompileSkip の件数を出力しました。

infer      コンパイルされた関数: Button, useCounter, buildRows / スキップ: 1件
annotation コンパイルされた関数: buildRows / スキップ: 0件
all        コンパイルされた関数: Button, useCounter, calcTotal, buildRows / スキップ: 1件
関数 infer(既定) annotation all
Button(コンポーネント) 対象 対象外 対象
useCounter(フック) 対象 対象外 対象
calcTotal(ふつうの関数) 対象外 対象外 対象
buildRows("use memo" つき) 対象 対象 対象
Legacy("use no memo" つき) スキップ 対象外 スキップ
モード 対象になるもの 向いている場面
infer コンポーネントらしい名前・フックらしい名前で、JSX を作るかフックを呼ぶ関数。加えて "use memo" を付けた関数 新しく作るアプリ、全体に入れるとき
annotation "use memo" を付けた関数だけ 既存のアプリに少しずつ入れるとき
all トップレベルのすべての関数 公式ドキュメントでは、React と関係のない関数まで対象になるため推奨されていない

infer モードは名前で判断するので、コンポーネントは大文字始まり、フックは use 始まりという命名の決まりを守っていることが前提になります。小文字始まりの関数が JSX を返していても、対象にはなりません。

"use memo" と "use no memo" の使い分け

どちらも、関数の本体の先頭に書く文字列(ディレクティブ)です。

ディレクティブ 働き 使う場面
"use memo" その関数をコンパイルの対象にする annotation モードで、対象を1つずつ増やすとき
"use no memo" その関数をコンパイルの対象から外す 導入後に表示がおかしくなったコンポーネントを、一時的に外すとき

"use no memo" を付けたコンポーネントを変換すると、キャッシュ配列のないコードがそのまま出力されました。

export function Legacy({
  name
}) {
  "use no memo";

  return <p>{name}</p>;
}

公式ドキュメントによると、"use no memo" はどのモードでも優先されます(上の表でも、infer と all の両方でスキップされています)。

使い方の流れは次のようになります。

flowchart TD
  A["コンパイラを入れた後に、表示や動きが変わった"] --> B["そのコンポーネントに use no memo を付ける"]
  B --> C{"元に戻ったか"}
  C -->|戻った| D["ESLint と logger で原因の書き方を探して直す"]
  D --> E["use no memo を外す"]
  C -->|戻らない| F["コンパイラ以外の原因を調べる"]

"use no memo" は、原因を直すまでの一時的な退避場所として使い、直したら外します。付けたままにすると、そのコンポーネントだけ自動メモ化の対象から外れ続けるためです。

React 17・18 で使うときの target 指定

コンパイラが出力するコードは、既定では React 19 に入っている react/compiler-runtime を読み込みます。React 17・18 にはこの入口がないので、target を指定し、react-compiler-runtime を追加で入れます。

npm install react-compiler-runtime@latest
// babel.config.js
module.exports = {
  plugins: [
    ["babel-plugin-react-compiler", { target: "18" }],
  ],
};

target を変えたときの、変換後の1行目です。

target: "19" => import { c as _c } from "react/compiler-runtime";
target: "18" => import { c as _c } from "react-compiler-runtime";
target: "17" => import { c as _c } from "react-compiler-runtime";

React 18.3.1 を入れた環境で、target を "19"(既定)のままにした場合と、"18" にした場合を実行して比べました。内容は、前の章のレンダー回数の確認と同じです。

React 18.3.1
target "19": ERR_PACKAGE_PATH_NOT_EXPORTED Package subpath './compiler-runtime' is not defined by "exports"
target "18": App=4回 Heavy=1回
使っている React target 追加で入れるもの
19 指定なし(既定の "19") なし
18 "18" react-compiler-runtime
17 "17" react-compiler-runtime
  • React 18 で target を指定しないと、compiler-runtime が見つからないというエラーになります。「Package subpath './compiler-runtime' is not defined」と出たら、target の指定を確かめます。
  • target を "18" にすると、React 18 でも Heavy のレンダーは1回になり、自動メモ化が働きました。
  • 公式ドキュメントによると、target は "18" のように文字列で書き、react-compiler-runtime は devDependencies ではなく dependencies に入れます(実行時に使われるため)。

既存の useMemo・useCallback を残すか消すかの判断表

すでに useMemo や useCallback を書いているコンポーネントは、そのままコンパイラに通せます。変換の結果を見てみます。

// src/Manual.jsx
import { useCallback, useMemo } from "react";

export function Manual({ items, onSelect }) {
  const sorted = useMemo(() => [...items].sort((a, b) => a.price - b.price), [items]);
  const handleClick = useCallback((id) => onSelect(id), [onSelect]);
  return (
    <ul>
      {sorted.map((i) => (
        <li key={i.id} onClick={() => handleClick(i.id)}>{i.title}</li>
      ))}
    </ul>
  );
}

変換後の前半です。

import { c as _c } from "react/compiler-runtime";
import { useCallback, useMemo } from "react";
export function Manual(t0) {
  const $ = _c(11);
  const {
    items,
    onSelect
  } = t0;
  let t1;
  if ($[0] !== items) {
    t1 = [...items].sort(_temp);
    $[0] = items;
    $[1] = t1;
  } else {
    t1 = $[1];
  }
  const sorted = t1;
  let t2;
  if ($[2] !== onSelect) {
    t2 = id => onSelect(id);
    $[2] = onSelect;
    $[3] = t2;
  } else {
    t2 = $[3];
  }
  const handleClick = t2;

useMemo と useCallback の呼び出しが、コンパイラのキャッシュ配列を使うコードに置き換わっています。 手書きのメモ化と自動メモ化が二重になるわけではなく、同じ意味のコードにまとめられます。

一方、前の章の 08 のように、手書きの依存配列がコンパイラの解析結果と合わないときは、「Existing memoization could not be preserved」として、そのコンポーネント全体の最適化が見送られます。手書きのメモ化が意図どおりに保てないと判断した場合に、勝手に動きを変えないための仕組みです。

公式ドキュメントは、既存のコードについては、手書きのメモ化を残しておくか、外す場合はよくテストしてから外すことをすすめています(外すとコンパイル結果が変わることがあるため)。これを踏まえた判断表です。

状況 判断 理由
既存のコードに useMemo・useCallback がある まずは残す 外すとコンパイル結果が変わることがある
新しく書くコード 書かずに始める コンパイラが同等のコードを入れる
ESLint が依存配列の不足を指摘している 依存配列を直す ずれたままだと、最適化が見送られることがある(08 の例)
エフェクトの依存に使う値を、確実に同じ参照に保ちたい useMemo・useCallback を残す 公式ドキュメントが、メモ化を自分で制御する手段として残してよいとしている
"use no memo" を付けたコンポーネント 手書きのメモ化を残す 自動メモ化の対象から外れているため
コンパイラを通さないコード(React 以外から使う関数など) これまでどおり infer モードでは対象にならない
消したい 1コンポーネントずつ消し、変換後のコードと動作を確かめる 一度に消すと、変化の原因を追いにくい

React.memo も同じ考え方で、既存のものは残したまま導入し、新しく書く分から省いていく進め方が無理なく続けられます。

既存のアプリへ少しずつ導入する手順

ここまでの内容を、導入の順番に並べます。

手順 すること 確かめること
1 eslint-plugin-react-hooks の recommended-latest を有効にする error がどのくらい出るかを把握する
2 error を直す(props の書き換え、ref、条件つきのフックなど) ESLint の指摘が減ること
3 babel-plugin-react-compiler を入れ、compilationMode を annotation にする ビルドが通ること
4 影響の小さいコンポーネントに "use memo" を付ける 変換後のコードに _c( があること、画面の動作
5 範囲を広げ、問題がなければ infer に切り替える DevTools の Memo ✨ バッジ
6 動きが変わったコンポーネントには "use no memo" を付け、原因を直してから外す logger の CompileError の理由

手順1と2は、コンパイラを入れる前でも進められます。ESLint の指摘を先に減らしておくと、導入したときに最適化されるコンポーネントの割合が増えます。

よくある質問

Q. React Compiler を入れたのに、再レンダーが減りません。 A. まず、変換後のコードに _c(数) が入っているかを確かめます。入っていなければ、ビルド設定(プラグインの順番、Vite なら @rolldown/plugin-babel の設定)を見直します。設定が正しいのに特定のコンポーネントだけ入っていない場合は、そのコンポーネントが最適化の対象外になっています。ESLint の recommended-latest と logger で理由を確かめます。

Q. ESLint は eslint-plugin-react-compiler を入れるのですか? A. 公式ドキュメントが案内しているのは eslint-plugin-react-hooks です。コンパイラ用のルールは recommended-latest に入っており、今回は 7.1.1 で immutability・refs・globals・set-state-in-render・purity などのルールが働くことを確かめました。

Q. CompileError とログに出ていますが、ビルドは通ります。直す必要はありますか? A. ビルドも画面も動きますが、そのコンポーネントには自動メモ化が入っていません。メッセージは React のルールから外れている場所を教えてくれているので、直すと最適化の対象になり、コードも読みやすくなります。今回は5つのパターンを直して、すべて CompileSuccess になることを確かめました。

Q. React 18 でも使えますか? A. 使えます。target を "18" にして react-compiler-runtime を入れると、React 18.3.1 でも子コンポーネントのレンダーが1回に抑えられました。target を指定しないと、compiler-runtime が見つからないエラーになります。

まとめ

  • React Compiler は、ビルド時にメモ化のコードを自動で入れます。 変換後のコードの _c(数) とキャッシュ配列が、その印です。
  • 導入は、Babel なら plugins の先頭、Vite 8 なら @rolldown/plugin-babel と reactCompilerPreset、Next.js 16 なら reactCompiler: true です。
  • React のルールから外れたコンポーネントは、エラーにならずに最適化が見送られます。 props の書き換え、レンダー中の ref、フックの条件呼び出し、外の変数への再代入、レンダー中の setState が代表例です。
  • eslint-plugin-react-hooks の recommended-latest を入れると、見送りの理由をエディタ上で確認できます。 logger を使うと、一覧でも確かめられます。
  • compilationMode は、全体に入れるなら infer、少しずつなら annotation と "use memo" を使います。"use no memo" は一時的な退避用です。
  • React 17・18 では target を指定し、react-compiler-runtime を入れます。
  • 既存の useMemo・useCallback はまず残し、新しいコードから省いていきます。 依存配列のずれは、最適化が見送られる原因になるので直します。

参考・出典

  • React 公式ドキュメント「React Compiler」Introduction https://react.dev/learn/react-compiler/introduction
  • React 公式ドキュメント「React Compiler」Installation https://react.dev/learn/react-compiler/installation
  • React 公式ドキュメント compilationMode https://react.dev/reference/react-compiler/compilationMode
  • React 公式ドキュメント target https://react.dev/reference/react-compiler/target
  • Next.js 公式ドキュメント reactCompiler https://nextjs.org/docs/app/api-reference/config/next-config-js/reactCompiler

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React Compilerの使い方と導入手順|自動メモ化が効かない原因」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?