React Compiler: 手動での useMemo と useCallback にさようなら?

1. 概要

多くの React プロジェクトには、useMemo、useCallback、React.memo がコードベース全体に散在しています。目的は不要な再レンダリングを避けることですが、代償としてコードが長くなり、依存関係配列のミスが起きやすくなります。React Compiler はこのメモ化をビルド時に処理します。コンポーネントを分析し、必要な箇所に自動的にキャッシュを挿入するのです。

この記事では、React Compiler が何であり、なぜ注目されているのかを説明します。Vite プロジェクトでコンパイラを有効にする方法、段階的に移行する方法、メモ化の動作をテストする方法、そして使用すべきでない場合について学びます。

2. 手動メモ化の問題

注文一覧ページと関連のないカウンターを持つフィルター入力がある場合を考えます:

import { useState, useMemo, useCallback, memo } from "react";

type Order = { id: number; customer: string; total: number };

const OrderList = memo(function OrderList({
  orders,
  onSelect,
}: {
  orders: Order[];
  onSelect: (id: number) => void;
}) {
  return (
    <ul>
      {orders.map((o) => (
        <li key={o.id} onClick={() => onSelect(o.id)}>
          {o.customer}: {o.total}
        </li>
      ))}
    </ul>
  );
});

export function OrdersPage({ orders }: { orders: Order[] }) {
  const [keyword, setKeyword] = useState("");
  const [clicks, setClicks] = useState(0);

  const filtered = useMemo(
    () => orders.filter((o) => o.customer.includes(keyword)),
    [orders, keyword]
  );

  const handleSelect = useCallback((id: number) => {
    console.log("Selected order", id);
  }, []);

  return (
    <>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <button onClick={() => setClicks((c) => c + 1)}>Clicks: {clicks}</button>
      <OrderList orders={filtered} onSelect={handleSelect} />
    </>
  );
}

コードは正しく動作しますが、3 つの脆弱な点があります:

  • OrderList から memo を削除すると、useMemo と useCallback の両方がほぼ無意味になります。
  • 依存関係配列から keyword を忘れると、フィルタリングされた一覧は古いデータを表示します。
  • style={{ color: "red" }} のようなオブジェクトリテラルという新しい prop を 1 つ追加するだけで、メモ化の努力全体が台無しになります。

手動メモ化は暗黙の契約です。コンポーネントを変更する人は誰もそれを理解する必要があり、エラーは通常、誰かがパフォーマンスをプロファイルするまで明らかになりません。

3. React Compiler とは

React Compiler はビルド時に実行されるツールであり、通常は Babel プラグインとして実装されます。コンポーネントと hook のコードを読み、データフローを理解してから、以下のためのキャッシュ機能を備えたコードを生成します:

  • 計算値:useMemo に相当します。
  • 関数:useCallback に相当します。
  • JSX 要素:子を memo でラップするのと相当します。要素の入力が変わらない場合、コンパイラは古い要素を再利用し、React はそのサブツリーの再レンダリングをスキップします。

以下はコンパイル後のコードの概念を示しています。これは実際の出力ではなく、簡略化されたバージョンです:

function OrdersPage({ orders }) {
  const $ = useMemoCache(6); // コンポーネントインスタンスに関連付けられたキャッシュ配列
  const [keyword, setKeyword] = useState("");

  let filtered;
  if ($[0] !== orders || $[1] !== keyword) {
    filtered = orders.filter((o) => o.customer.includes(keyword));
    $[0] = orders;
    $[1] = keyword;
    $[2] = filtered;
  } else {
    filtered = $[2];
  }
  // ... handleSelect と JSX 要素についても同様
}

コンパイラは各入力を前回のレンダリング時の値と比較します。入力が変わった場合のみ再計算します。手動メモ化と比較して、2 つの重要な違いがあります:

  1. より詳細:コンパイラは条件文の後にあるコード片もメモ化できます。これは hook の条件付き呼び出しが許可されないため、useMemo では不可能です。
  2. 手書き依存関係配列がない:依存関係はコードから推測されるため、「忘れる」ことはできません。

4. React Compiler が注目されている理由

いくつかの理由でこのトピックが多く言及されています:

  • ボイラープレートの削減:コードは「純粋な React」の形に戻り、読みやすくレビューしやすくなります。
  • デフォルトでより良いパフォーマンス:チームは不要な再レンダリングを避けるためにパフォーマンスプロファイリング専門家が必要ありません。
  • React ルールの遵守を強制:コンパイラは「正しい」コードのみを最適化できます。そのため、コンパイラを有効にしていない場合でも、ルール違反を検出するのに役立つ lint ルールが付属しています。
  • 段階的な互換性:フォルダーまたはコンポーネントのみを有効にでき、アプリケーション全体を書き直す必要はありません。

注意: コンパイラはすべての場所でコードを高速化しません。不要な再レンダリングを削減します。ボトルネックが network、バンドルサイズ、または入力が変わるたびに実行される重い loop である場合、コンパイラは役に立ちません。

5. プロジェクトで React Compiler を有効にする

5.1. Vite での設定

プラグインと lint をインストールします。パッケージ名は時間の経過とともに変わる可能性があるため、使用している React のバージョンについて公式ドキュメントを確認してください。

npm install -D babel-plugin-react-compiler eslint-plugin-react-hooks

Vite 設定に plugin を追加します:

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

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

コンパイラは Babel プラグインチェーン内で最初に実行する必要があります。他のプラグインが変換する前に元のコードを読む必要があるためです。

プロジェクトが古い React バージョンを使用している場合、コンパイラは互換性のあるコードを生成するために追加の runtime パッケージと target オプションが必要です。これはバージョンに依存するため、設定する前にドキュメントを確認してください。

Next.js や Expo などのフレームワークの場合、通常、コンパイラを有効にするための専用設定フラグがあります。フラグの名前はバージョン間で変わっている可能性があるため、古い設定をコピーするのではなく、フレームワークのリリースノートを読んでください。

5.2. コンパイラが機能しているかを確認

React DevTools を開きます。コンパイラで最適化されたコンポーネントには、名前の横に「Memo ✨」のようなラベルが表示されます。ラベルがない場合は、コンパイラがコンポーネントをスキップしています。通常、セクション 6 のルール違反が原因です。

5.3. Lint を有効にする

// eslint.config.js
import reactHooks from "eslint-plugin-react-hooks";

export default [
  reactHooks.configs.recommended,
];

Lint は、コンパイラが無視したり、不正に動作する可能性があるコードセグメントの事前警告を表示します。コンパイラの前に lint を有効にして、すべての警告を修正する必要があります。

6. React ルール:コンパイラが機能するための条件

コンパイラは、レンダリング時にコンポーネントが純粋な関数であると想定しています。次の例はそれを違反しています:

export function CartSummary({ items }: { items: { price: number }[] }) {
  items.sort((a, b) => b.price - a.price); // props を変更!
  const total = items.reduce((sum, i) => sum + i.price, 0);
  return <p>Top item: {items[0]?.price}, total: {total}</p>;
}

items.sort は渡された配列を直接変更します。キャッシュがある場合、コンパイラは items が「変更」されたことに気づかない可能性があります。参照は同じままであるため、UI が正しく表示されません。Lint は次のようなメッセージを報告します:

error  Mutating a value returned from a function whose return value should not be mutated  react-hooks/...

正確なメッセージはプラグインのバージョンに依存します。修正するには、コピーを作成します:

const sorted = [...items].sort((a, b) => b.price - a.price);

一般的な違反:

  • props、state、または hook から返された値を変更します。
  • レンダリング中に ref.current を読み取りまたは書き込みます。
  • 条件付きまたはループ内で hook を呼び出します。
  • コンポーネント本体での直接的な副作用。例えば、モジュール変数への書き込みや API 呼び出し。

このようなコードに遭遇した場合、コンパイラは通常、それを破壊するのではなくそのコンポーネントをスキップします。これは安全な動作ですが、最適化の一部を失うことになります。

7. 大規模プロジェクトでの段階的な移行

7.1. opt-in モード

大規模な codebase の場合、マークされたコンポーネントのみをコンパイルできます:

// vite.config.js (抜粋)
plugins: [["babel-plugin-react-compiler", { compilationMode: "annotation" }]],
export function InvoiceTable({ invoices }: { invoices: Invoice[] }) {
  "use memo";
  // ... このコンポーネントはコンパイルされます
}

関数本体の先頭にある "use memo" ディレクティブは、コンパイラにこのコンポーネントを処理するよう指示します。他のコンポーネントは変わりません。

7.2. コンポーネントを一時的に除外

グローバルに有効にしても 1 つのコンポーネントがエラーを引き起こす場合、そのコンポーネントのみコンパイラを無効にできます:

export function LegacyChart(props: ChartProps) {
  "use no memo";
  // ... データを変更するレガシーライブラリを使用する古いコード
}

注意: "use no memo" は一時的な抜け道です。理由を記録し、修正するためのタスクを作成してください。これをデフォルトにしないでください。

7.3. 古い useMemo はどうする?

コンパイラは既に useMemo/useCallback を含むコードでも動作するため、すぐに削除する必要はありません。大量の削除は微妙な動作の変化を導入する可能性があります。特に、値が useEffect の依存関係として使用されている場合です。段階的に削除し、テストを実施してください。新しいコンポーネントの場合は、純粋なコードを記述し、コンパイラに任せてください。

8. メモ化の動作についてのテストを書く

子が無関連の state が変わった場合に再レンダリングされないことを確認できます。以下の例は Vitest と React Testing Library を使用し、コンパイラが有効な同じ Vite 設定で実行されます:

npm install -D vitest @testing-library/react @testing-library/user-event jsdom
// OrdersPage.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";

const renderSpy = vi.fn();

function OrderList({ orders }: { orders: string[] }) {
  renderSpy();
  return <ul>{orders.map((o) => <li key={o}>{o}</li>)}</ul>;
}

function OrdersPage() {
  const [clicks, setClicks] = useState(0);
  const orders = ["Alice", "Bob"]; // useMemo を使用していません
  return (
    <>
      <button onClick={() => setClicks((c) => c + 1)}>Clicks: {clicks}</button>
      <OrderList orders={orders} />
    </>
  );
}

describe("React Compiler を使用した OrdersPage", () => {
  it("カウンターのみが変わった場合、OrderList を再レンダリングしない", async () => {
    render(<OrdersPage />);
    await userEvent.click(screen.getByRole("button"));
    await userEvent.click(screen.getByRole("button"));

    expect(screen.getByRole("button")).toHaveTextContent("Clicks: 2");
    expect(renderSpy).toHaveBeenCalledTimes(1);
  });
});

説明:

  • orders はリテラル配列で、useMemo にラップされていません。コンパイラがない場合、毎回のレンダリングで新しい配列が作成され、OrderList が再レンダリングされます。
  • コンパイラを使用すると、orders と <OrderList /> 要素の両方がキャッシュされます。これらは clicks に依存しないためです。
  • renderSpy は 1 回だけ呼び出されます。最初のレンダリングの時です。

期待される結果:

✓ React Compiler を使用した OrdersPage > カウンターのみが変わった場合、OrderList を再レンダリングしない
Test Files  1 passed (1)

コンパイラを無効にした場合、最後のアサーション値が 3 になります。matcher toHaveTextContent には、セットアップファイル内の @testing-library/jest-dom が必要です。

注意: コンポーネント本体でスパイを呼び出すのは副作用です。この方法はレンダリング回数を測定するテストでのみ許容されます。本番コードには適用しないでください。

9. 使用すべきでない場合(または慎重に)

  • React ルール違反が多い codebase:コンパイラはほとんどのコンポーネントをスキップするか、隠れていたバグを露出させます。Lint に従って修正してください。
  • Mutation に依存するライブラリ:一部のライブラリは state またはフォームのオブジェクトをその場で変更し、コンポーネントが毎回のレンダリングで新しい値を読むことを期待しています。これを使用するコンポーネントの場合、"use no memo" が必要な場合があります。
  • 独特の build pipeline:Babel を使用していない場合、またはツールチェーンがプラグインをサポートしていない場合、統合に手間がかかります。統合する前に bundler のサポートを確認してください。
  • 小規模アプリケーション、少ないインタラクション:利点は、build 時間の増加とデバッグの複雑さ増加の価値がない場合があります。
  • Effect の安定した参照を必要とする場合:コンパイラはメモ化の最適化を行いますが、セマンティクスは保証しません。値が参照を保持することに正/誤の論理が依存している場合、例えば effect が再実行されないようにするため、useMemo/useCallback を明示的に保持して意図を示してください。

さらに、デバッグするときは、ブラウザで実行されるコードはあなたが書いたコードと異なります。Source map は役立ちますが、必要に応じてコンパイラの出力を表示する DevTools とプレイグラウンドツールに慣れる必要があります。

10. まとめ

React Compiler は useMemo と useCallback をなくすわけではなく、日常的なツールから特殊なケースの抜け道に変えます。前提条件は、コードが React ルールに従う必要があることです。したがって、最初のステップは常に lint を有効にして違反を修正することです。

推奨される手順:lint を有効にして警告を修正し、いくつかのコンポーネントに対して annotation モードでコンパイラを有効にし、レンダリング回数を測定するテストを書き、その後、段階的に拡張します。そうすれば、手動で依存関係配列を書かずに、より良いパフォーマンスが得られます。

reactreact-compilerperformancefrontendtech-trends