RAKSUL TechBlog

RAKSULグループのエンジニアが技術トピックを発信するブログです

React Compilerについて調べてみた

こんにちは。ノバセル事業本部エンジニアの宮本です。 本記事はノバセル テクノ場 出張版 2025 Advent Calendar 2025 の 21 日目の記事になります。

はじめに

Claude Code、GitHub Copilot、Cursor——AI アシスタントと一緒にコードを書く「バイブコーディング」が当たり前になりました。しかし、生成された React コードをレビューしていると、ある傾向に気づきます。

const UserList = ({ users }: { users: User[] }) => {
  const sortedUsers = useMemo(
    () => users.sort((a, b) => a.name.localeCompare(b.name)),
    [users]
  );

  const handleClick = useCallback((id: string) => {
    console.log(id);
  }, []);

  return (
    <ul>
      {sortedUsers.map((user) => (
        <li key={user.id} onClick={() => handleClick(user.id)}>
          {user.name}
        </li>
      ))}
    </ul>
  );
};

AI はuseMemouseCallbackを多用します。「パフォーマンス最適化のベストプラクティス」として学習しているからでしょう。しかし、React 公式ドキュメントを読むと、これらの Hooks は計算コストの高い処理でなければ、むしろ初期化のオーバーヘッドが大きくなる可能性があると書かれています。

レビューのたびに「このメモ化は本当に必要か?」と悩むのは生産的ではありません。レビューの判断軸を求めてドキュメントを読んでいると、React Compilerという解決策に出会いました。これは手動でのメモ化を不要にする、ビルドタイムの最適化ツールです。

この記事では、React Compiler がどのような仕組みで動作するのかを、React Summit 2025 での Lydia Hallie の発表「React Compiler Internals」を参考にしながら解説します。


なぜメモ化が必要なのか

React Compiler の内部に入る前に、そもそもなぜメモ化が必要なのかを確認しましょう。

const users = [
  { id: 1, name: "Alice", active: true },
  { id: 2, name: "Bob", active: false },
  { id: 3, name: "Charlie", active: true },
];

function UserStats({ users }: { users: User[] }) {
  const activeUsers = users.filter((u) => u.active);
  return <div>Active: {activeUsers.length}</div>;
}

function App() {
  const [sort, setSort] = useState<"asc" | "desc">("asc");

  return (
    <>
      <UserStats users={users} />
      <button onClick={() => setSort((s) => (s === "asc" ? "desc" : "asc"))}>
        Sort: {sort}
      </button>
    </>
  );
}

このコードには大きなパフォーマンス問題があります。Appsortステートが変わると、Appが再レンダリングされ、子コンポーネントであるUserStatsも再レンダリングされます。React のデフォルト動作として、親が再レンダリングされれば子も再レンダリングされるからです。

UserStatsが再レンダリングされると、users.filter()が再実行されます。3 件のデータなら問題ありませんが、10,000 件だったらどうでしょうか。users配列は変わっていないのに、sortが変わっただけで重い処理が走ってしまいます。

従来の解決策は、React.memoでコンポーネントをラップするか、useMemoで計算結果をキャッシュすることでした。

function UserStats({ users }: { users: User[] }) {
  const activeUsers = useMemo(() => users.filter((u) => u.active), [users]);
  return <div>Active: {activeUsers.length}</div>;
}

しかし、この手動メモ化には問題があります。

  1. 依存配列の管理が煩雑: 依存を追加し忘れると、値が更新されないバグが生じる
  2. どこにメモ化を入れるべきか判断が難しい: 入れすぎても入れなさすぎても問題
  3. コードの可読性が下がる: ビジネスロジックとパフォーマンス最適化が混在する

React Compiler は、これらの問題を自動的に解決します。


React Compiler とは

React Compiler は、ビルドタイムに React コードを解析し、適切な場所に自動でメモ化を挿入するツールです。Babel プラグインとして実装されていますが、内部は Babel から独立した独自のコンパイラです。

2025 年 10 月に v1.0 の安定版がリリースされ、React 17 以上で利用可能になりました。Meta では既に Quest Store などで本番導入されており、初期ロードで最大 12%、特定のインタラクションで 2.5 倍以上の改善が報告されています。

開発者はメモ化を意識することなくコードを書け、コンパイラが最適化されたコードを生成します。しかも、手動では実現が難しい粒度の細かいメモ化も可能です。では、どのような仕組みでそれを実現しているのでしょうか。


コンパイルパイプラインの全体像

React Compiler は、以下のフェーズを経てコードを変換します。

JavaScript
    ↓
HIR(High-level Intermediate Representation)
    ↓
SSA(Single Static Assignment)
    ↓
Type Inference(型推論)
    ↓
Effect Analysis(エフェクト解析)
    ↓
Reactive Analysis(リアクティブ解析)
    ↓
Scope Discovery(スコープ発見)
    ↓
Code Generation(コード生成)

それぞれのフェーズを詳しく見ていきましょう。


Phase 1: HIR(High-level Intermediate Representation)

なぜ中間表現が必要か

JavaScript のコードをそのまま解析するのは困難です。変数のスコープ、条件分岐、クロージャなど、複雑な要素が絡み合っています。コンパイラはまず、コードをより扱いやすい形式に変換します。

HIR は、各操作を個別の命令として分離した中間表現です。

// 元のコード
function UserStats({ users }) {
  const activeUsers = users.filter((u) => u.active);
  return <div>{activeUsers.length}</div>;
}
// HIR(簡略化)
1: $users = LoadParam('users')
2: $filter = LoadProperty($users, 'filter')
3: $callback = CreateFunction(...)
4: $activeUsers = Call($filter, $users, $callback)
5: $length = LoadProperty($activeUsers, 'length')
6: $jsx = CreateJSX('div', {}, $length)
7: Return($jsx)

データフローの可視化

HIR の重要な点は、データの流れが明確になることです。命令 7 のReturnは命令 6 の$jsxを使い、命令 6 は命令 5 の$lengthを使い...というように、依存関係が追跡可能になります。

これにより、「この値が変わったら、どの計算をやり直す必要があるか」をコンパイラが判断できるようになります。


Phase 2: SSA(Single Static Assignment)

変数の再代入問題

HIR には一つ問題があります。変数が再代入される場合、どの値を参照しているか曖昧になることです。

function UserStats({ users, filters }) {
  let activeUsers = users.filter((u) => u.active);

  if (filters.limitResults) {
    activeUsers = activeUsers.slice(0, 10);
  }

  return <div>{activeUsers.length}</div>;
}

このコードでは、activeUsersが 2 つの異なる値を持つ可能性があります。filters.limitResultstrueならsliceされた配列、falseならフィルタされただけの配列です。

コンパイラはビルドタイムに実行時の値を知ることができないため、このままでは安全に最適化できません。

SSA 形式への変換

この問題を解決するのが SSA(Static Single Assignment)です。各変数が一度だけ代入される形式に変換します。

// 上記のactiveUsersをSSA形式で表すと
const activeUsers_1 = users.filter((u) => u.active);
const activeUsers_2 = activeUsers_1.slice(0, 10);

再代入のたびに新しい変数名が割り当てられるため、どの時点の値を参照しているかが常に明確です。これによりコンパイラは各変数の定義と使用箇所を正確に追跡でき、安全な最適化が可能になります。


Phase 3: Type Inference(型推論)

コンパイラは、各値の型を推論します。

  • プリミティブ値(number, string, boolean
  • オブジェクト / 配列
  • 関数
  • React 要素
  • Hook の戻り値

型情報は、次のエフェクト解析で重要になります。例えば、プリミティブは不変なのでキャッシュが安全ですが、オブジェクトは変更される可能性があります。


Phase 4: Effect Analysis(エフェクト解析)

エフェクトとは何か

エフェクトは、各操作がデータにどう影響するかを表します。メモ化が安全かどうかを判断するために必要な情報です。

React Compiler は 6 種類のエフェクトを定義しています。

Effect 説明
Read 値を読み取るだけ .length, .filter へのアクセス
Store 新しい値を格納 const x = ...
Capture 外部スコープから値を取り込む クロージャで親スコープの変数を参照
Mutate 値を変更する可能性 配列の.push(), オブジェクトのプロパティ変更
Freeze 値を不変にする キャッシュ後の値

キャッシュの安全性

  • Read: データを変更しないので、キャッシュは常に安全
  • Store: 格納する値が不変なら安全
  • Capture: 依存関係として追跡が必要
  • Mutate: キャッシュすると危険な可能性あり
  • Freeze: キャッシュ後に変更されないことを保証
// Read effect - 安全
const length = users.length;

// Mutate effect - 危険
users.push(newUser); // 元の配列を変更

エフェクト解析により、「この操作の結果をキャッシュしても安全か」をコンパイラが判断できます。


Phase 5: Reactive Analysis(リアクティブ解析)

React における「リアクティブ」とは

ここまでで、コンパイラは各操作の振る舞いを理解しました。しかし、まだ重要な情報が欠けています。それは、どの値がレンダー間で変化しうるかです。

React でメモ化が効果を発揮するのは、「前回のレンダーと今回のレンダーで値が同じなら、計算をスキップする」場合です。つまり、レンダー間で変化しない値をメモ化しても意味がありません。

リアクティブな値の伝播

リアクティブ解析は、以下のルールで値の「リアクティブ性」を判定します。

リアクティブの起点:

  1. 関数パラメータ(props): 親から渡されるので、常にリアクティブ
  2. Hook の戻り値: useState, useContextなどの値

伝播ルール:

  • リアクティブな値を入力に持つ操作の出力は、リアクティブになる
function UserStats({ users }) {
  // users はリアクティブ(props)
  const activeUsers = users.filter((u) => u.active); // リアクティブ
  const count = activeUsers.length; // リアクティブ
  return <div>{count}</div>; // リアクティブ
}

usersがリアクティブなので、それに依存するactiveUsersもリアクティブ、さらにそれに依存するcountもリアクティブ...と伝播していきます。

なぜこれが重要か

リアクティブでない値をメモ化しても意味がありません。例えば、コンポーネント外で定義された定数はレンダー間で変わらないので、キャッシュする必要がありません。

const ITEMS_PER_PAGE = 10; // リアクティブではない

function List({ items }) {
  // ITEMS_PER_PAGEはメモ化不要
  // itemsはリアクティブなのでメモ化対象
  const pagedItems = items.slice(0, ITEMS_PER_PAGE);
}

Phase 6: Scope Discovery(スコープ発見)

メモ化の粒度を決める

ここまでで、コンパイラは以下を把握しています。

  • 各操作の依存関係(HIR, SSA)
  • 各操作の副作用(Effect Analysis)
  • どの値がレンダー間で変化しうるか(Reactive Analysis)

次のステップは、どの操作をまとめてキャッシュするかを決めることです。

スコープとは

スコープは、一緒にキャッシュされる操作のグループです。同じ依存を持つ操作は同じスコープに入り、独立した依存を持つ操作は別のスコープになります。

function UserStats({ users }) {
  // Scope 1: usersに依存
  const activeUsers = users.filter((u) => u.active);

  // unscopedな計算
  const count = activeUsers.length;

  // Scope 2: countに依存
  return <div>{count}</div>;
}
  • Scope 1: usersが変わったら再計算、変わらなければキャッシュを使用
  • Scope 2: countが変わったら再生成、変わらなければキャッシュを使用

手動メモ化を超える最適化

ここで重要なのは、Scope 1 と Scope 2 が独立していることです。

users配列の内容が変わっても、filter後のlengthが同じなら(例: active なユーザーが 10 人 →10 人)、JSX は再生成されません。

これを手動で実現しようとすると、非常に複雑なコードになります。

// 手動で同じ最適化を実現しようとすると...
function UserStats({ users }) {
  const activeUsers = useMemo(() => users.filter((u) => u.active), [users]);

  const count = activeUsers.length;

  // countだけに依存するJSXのメモ化は...?
  // React.memoでラップした子コンポーネントに分離する必要がある
}

React Compiler は、このような細粒度の最適化を自動で行います。

純粋関数の最適化

外部の変数に依存しない純粋関数は特別な扱いを受けます。

function UserStats({ users }) {
  const activeUsers = users.filter((u) => u.active);
  //                              ^^^^^^^^^^^^^^^^
  //                              このコールバックは独立したスコープ
}

u => u.activeというコールバックは、外部の変数に依存していません。このような関数は一度だけ生成してすべてのレンダーで再利用できます。コンパイラはこれを検出し、関数を抽出します。


Phase 7: Code Generation(コード生成)

キャッシュスロットの確保

最終フェーズでは、HIR を最適化された JavaScript コードに変換します。

まず、スコープごとにキャッシュスロットを確保します。各スコープには 2 種類のスロットが必要です。

  1. 依存の値を保存するスロット: 前回の値と比較するため
  2. 出力の値を保存するスロット: キャッシュヒット時に返すため
function UserStats({ users }) {
  const $ = useMemoCache(4); // 4つのスロット
  // $[0]: users の前回の値
  // $[1]: activeUsers の前回の値
  // $[2]: count の前回の値
  // $[3]: JSX の前回の値
}

生成されるコード

最終的に生成されるコードは以下のようになります。

function UserStats({ users }) {
  const $ = _c(4); // 4つのキャッシュスロット
  let t0;
  // Scope 1: usersが変わったか?
  if ($[0] !== users) {
    t0 = users.filter((u) => u.active);
    $[0] = users; // 依存をキャッシュ
    $[1] = t0; // 出力をキャッシュ
  } else {
    t0 = $[1]; // キャッシュから取得
  }

  // Scopeのない値は毎回実行する
  const activeUsers = t0;
  const count = activeUsers.length;

  let t1;
  // Scope 2: countが変わったか?
  if ($[2] !== count) {
    t1 = <div>{count}</div>;
    $[2] = count;
    $[3] = t1;
  } else {
    t1 = $[3];
  }

  return t1;
}

各スコープは独立して動作します。usersが変わってもフィルタ後の count が同じなら、最初のスコープだけが再計算され、JSX のスコープはキャッシュを返します。


React Compiler の限界

React Compiler は万能ではありません。以下のケースでは最適化が効きません。

1. React のルール違反

// ❌ 条件付きHook呼び出し
function Bad({ condition }) {
  if (condition) {
    const [state, setState] = useState(0); // Hook呼び出しが条件内
  }
}

// ❌ レンダー中の副作用
function Bad({ items }) {
  items.push("new"); // propsを直接変更
  return <List items={items} />;
}

コンパイラは React のルールに則ったコードを前提としています。違反があると最適化をスキップします。

2. クラスコンポーネント

React Compiler は関数コンポーネントのみをサポートしています。クラスコンポーネントは対象外です。

3. 複雑な JavaScript パターン

コンパイラが安全性を判断できない複雑なパターンでは、メモ化しないという保守的な選択をします。安全でないメモ化よりも、メモ化しない方がマシだからです。


コードベースの準備状況をチェックする

React Compiler を導入する前に、コードベースが React ルールに従っているかチェックできます。

npx react-compiler-healthcheck

このコマンドは、プロジェクトを解析し、React ルール違反や互換性の問題を報告します。

また、eslint-plugin-react-compiler を使うと、コンパイラが最適化できないコードを検出できます。

npm install eslint-plugin-react-compiler
// .eslintrc.js
module.exports = {
  plugins: ["react-compiler"],
  rules: {
    "react-compiler/react-compiler": "error",
  },
};

まとめ

React Compiler は、AI が生成しがちな過剰なuseMemo/useCallbackの問題を根本から解決します。 コンパイラの恩恵を最大限に受けるには React ルールに則ったコードが前提ですが、バイブコーディングのルールにこれを上手く組み込むことでレビュー時の悩みを一つ減らすことができるかもしれません。


参考リンク