•20 min read

React19Compiler徹底解説:useMemo、useCallbackの不要化とProfilerベンチマーク

React19Compiler徹底解説:useMemo、useCallbackの不要化とProfilerベンチマーク

React 19コンパイラは、コードネーム「Forget」として内部的に開発され、自動メモ化によってReactの再調整モデルを根本的に変革します。このガイドでは、その動作メカニズム、実用的な統合、およびパフォーマンスへの影響について詳しく説明し、手動のuseMemoとuseCallbackフックの排除に焦点を当てます。

Audio Briefing
0:00 / 0:00

コンパイラのIRと自動メモ化

ReactコンパイラはBabelトランスフォームとして動作し、JavaScript/TypeScriptのソースコードを分析して参照的に安定した値を特定し、メモ化の境界を自動的に挿入します。その核となる原則は、コンポーネント本体の不要な再実行やオブジェクト/関数の再作成を防ぐことで、UIの必要な部分のみを再レンダリングすることです。

コンパイラのIR(中間表現)は、コンポーネント関数を分析して、どの式がレンダリング間で参照的に安定しているかを判断します。プロパティ、ステート、コンテキスト、およびこれらから派生した値である「リアクティブな値」を識別し、その依存関係を追跡します。コンポーネントが再レンダリングされると、コンパイラはこれらのリアクティブな依存関係が変更されたかどうかをチェックするコードを生成します。変更されていない場合、以前に計算された値または関数参照が再利用されます。

アイテムのリストをレンダリングするコンポーネントを考えてみましょう。コンパイラがない場合、renderItem関数とmemoizedData配列は、itemsとonClickが変更されていなくても、親が再レンダリングされるたびに再作成されます。

// Before React 19 Compiler
import React, { useMemo, useCallback } from 'react';

interface Item {
  id: string;
  name: string;
}

interface MyListProps {
  items: Item[];
  onClick: (id: string) => void;
}

function MyList({ items, onClick }: MyListProps) {
  // Manual memoization required for referential stability
  const renderItem = useCallback((item: Item) => {
    return (
      <li key={item.id} onClick={() => onClick(item.id)}>
        {item.name}
      </li>
    );
  }, [onClick]); // Dependency on onClick

  const memoizedData = useMemo(() => {
    return items.map(item => ({ ...item, processed: true }));
  }, [items]); // Dependency on items

  return (
    <ul>
      {memoizedData.map(renderItem)}
    </ul>
  );
}

export default MyList;

React 19コンパイラを使用すると、明示的なuseMemoとuseCallbackの呼び出しは不要になります。コンパイラのIRは、renderItemとmemoizedDataがプロパティ(items、onClick)から派生していることを検出し、それらの定義を自動的にメモ化チェックでラップします。

// After React 19 Compiler (conceptual, compiler inserts the actual memoization)
import React from 'react'; // No useMemo/useCallback needed

interface Item {
  id: string;
  name: string;
}

interface MyListProps {
  items: Item[];
  onClick: (id: string) => void;
}

function MyList({ items, onClick }: MyListProps) {
  // Compiler automatically memoizes this function
  const renderItem = (item: Item) => {
    return (
      <li key={item.id} onClick={() => onClick(item.id)}>
        {item.name}
      </li>
    );
  };

  // Compiler automatically memoizes this array creation
  const memoizedData = items.map(item => ({ ...item, processed: true }));

  return (
    <ul>
      {memoizedData.map(renderItem)}
    </ul>
  );
}

export default MyList;

MyListに対するコンパイラの出力は、概念的には次のようになります。

// Simplified conceptual output from React Compiler
function MyList(props) {
  const { items, onClick } = props;

  // Compiler-generated memoization for renderItem
  const renderItem = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.memoize(
    () => (item) => {
      return React.createElement("li", {
        key: item.id,
        onClick: () => onClick(item.id)
      }, item.name);
    },
    [onClick] // Compiler infers dependencies
  );

  // Compiler-generated memoization for memoizedData
  const memoizedData = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.memoize(
    () => items.map(item => ({ ...item, processed: true })),
    [items] // Compiler infers dependencies
  );

  return React.createElement("ul", null, memoizedData.map(renderItem));
}

この内部のmemoize関数は簡略化された表現であり、実際の実装にはより洗練されたチェックと最適化が含まれます。重要なのは、コンパイラが依存配列の推論とメモ化ロジックを処理することで、ボイラープレートと潜在的なヒューマンエラーを削減することです。

Advertisement

設定とESLint統合

Reactコンパイラを有効にするには、ビルドパイプラインに統合する必要があります。Babelベースのセットアップ(Create React AppやNext.jsなど)の場合、これはBabelプラグインを追加することを意味します。

Next.jsの設定

Next.jsの場合、next.config.jsで実験的なコンパイラを有効にします。

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  experimental: {
    reactCompiler: true, // Enable the React Compiler
  },
};

module.exports = nextConfig;

ESLintルール

eslint-plugin-react-compilerパッケージは、コンパイラが効果的に最適化するのを妨げる可能性のあるコードパターンを特定したり、冗長なuseMemo/useCallback呼び出しを削除したりするのに役立つリンティングルールを提供します。

プラグインをインストールします。

npm install --save-dev eslint-plugin-react-compiler
# or
yarn add --dev eslint-plugin-react-compiler

.eslintrc.js(または.eslintrc.json)を設定します。

// .eslintrc.js
module.exports = {
  // ... other ESLint configurations
  plugins: [
    // ... other plugins
    'react-compiler',
  ],
  rules: {
    // ... other rules
    'react-compiler/react-compiler': 'error', // Enable the compiler lint rule
  },
};

このルールは、useMemoまたはuseCallbackが不要になったインスタンス、またはコードパターンがコンパイラの最適化を妨げる可能性のあるインスタンス(例:依存関係内のミュータブルなオブジェクト)にフラグを立てます。

生成された出力の検査とプロファイラベンチマーク

コンパイラの効果を理解するには、生成されたコードを検査し、ランタイムパフォーマンスをプロファイリングする必要があります。

生成されたコードの検査

コンパイラを有効にすると、ビルド出力には変換されたコードが含まれます。Babelの出力を直接検査すると冗長になる可能性がありますが、コンパイラの有効化を確認できます。より分かりやすい表示には、AST ExplorerのようなツールをBabelプラグインと一緒に使用するか、distディレクトリ内のコンパイル済みバンドルを調べるとよいでしょう。

重要なのは、ソースコードに明示的なuseMemoとuseCallbackの呼び出しがないこと、そしてコンパイル済み出力にコンパイラが生成したメモ化ロジックが存在することを確認することです。

Chrome DevToolsプロファイラ

Chrome DevToolsの「Performance」タブは、ベンチマークに不可欠です。

  1. プロファイルの記録: DevToolsを開き、「Performance」タブに移動して、記録ボタンをクリックします。アプリケーションを操作し、最適化したいコンポーネントの再レンダリングをトリガーします。
  2. フレームチャートの分析: 「User Timing」セクションを探します。コンポーネントのレンダリングを含むReactの内部タイミングが表示されます。
  3. 再レンダリングの特定: 「Main」スレッドに注目します。最適化されていないコンポーネントは、関数本体全体の頻繁な再実行を示します。コンパイラを使用すると、プロパティやステートが変更されていないコンポーネントの再レンダリングが少なくなるはずです。
  4. 比較(前/後): コンパイラを有効にせずにアプリケーションを実行し、プロファイルを記録します。次に、コンパイラを有効にし、同じインタラクションパターンで別のプロファイルを記録します。「Render」時間とコンポーネント関数実行の数を比較します。

プロファイラのシナリオ例:

カウンターを更新し、MyListを再レンダリングさせる親コンポーネントAppを考えます。

// App.tsx
import React, { useState } from 'react';
import MyList from './MyList'; // MyList from previous example

interface Item {
  id: string;
  name: string;
}

const initialItems: Item[] = Array.from({ length: 1000 }, (_, i) => ({
  id: String(i),
  name: `Item ${i}`,
}));

function App() {
  const [count, setCount] = useState(0);

  const handleClick = (id: string) => {
    console.log(`Clicked item: ${id}`);
  };

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={() => setCount(c => c + 1)}>Increment Count</button>
      <MyList items={initialItems} onClick={handleClick} />
    </div>
  );
}

export default App;

プロファイラの観察(概念):

メトリックコンパイラなし(手動メモ)コンパイラあり(自動メモ)
MyList レンダリング回数1(初回) + 1(count変更時)1(初回) + 0(count変更時)
MyList 実行時間約50ms(初回) + 約50ms(再レンダリング)約50ms(初回) + 約5ms(再レンダリング、メモ化チェックのため)
renderItem 再作成はい、Appが再レンダリングされるたびにいいえ、コンパイラによってメモ化されます
memoizedData 再作成はい、Appが再レンダリングされるたびにいいえ、コンパイラによってメモ化されます

注:これらは例示的な値です。実際のパフォーマンス向上は、コンポーネントの複雑さと再レンダリングの頻度によって異なります。

プロファイラから得られる重要な洞察は、親のAppが再レンダリングされても、MyListのプロパティ(items、onClick)が変更されていない場合、MyListの内部ロジック(renderItemやmemoizedDataの作成など)がスキップされることです。

本番環境での注意点とトラブルシューティング

Reactコンパイラはメモ化を簡素化しますが、新たな考慮事項も導入します。

1. オブジェクトのミューテーションと参照の等価性

コンパイラは参照の等価性に大きく依存しています。プロパティとして渡された、またはステートで使用されているオブジェクトや配列を変更すると、コンパイラは変更を検出せず、古いデータや再レンダリングの欠落につながります。

問題:

function BadComponent({ data }: { data: { value: number } }) {
  // Compiler assumes 'data' is referentially stable if its reference doesn't change.
  // Mutating 'data.value' directly will not trigger a re-render if 'data' itself is the same object.
  data.value++; // DANGER: Direct mutation
  return <div>Value: {data.value}</div>;
}

function Parent() {
  const [obj, setObj] = useState({ value: 0 });
  // This will NOT cause BadComponent to re-render when obj.value is mutated internally
  // because the 'obj' reference itself doesn't change.
  return <BadComponent data={obj} />;
}

修正: プロパティとステートオブジェクトは常にイミュータブルとして扱います。更新時には新しいオブジェクトを作成します。

function GoodComponent({ data }: { data: { value: number } }) {
  // Compiler correctly detects change if 'data' is a new object
  return <div>Value: {data.value}</div>;
}

function Parent() {
  const [obj, setObj] = useState({ value: 0 });

  const updateValue = () => {
    // Create a new object to ensure referential equality changes
    setObj(prev => ({ ...prev, value: prev.value + 1 }));
  };

  return (
    <>
      <button onClick={updateValue}>Update Value</button>
      <GoodComponent data={obj} />
    </>
  );
}

2. 古い値をキャプチャするクロージャ

コンパイラは関数のメモ化を処理しますが、特にuseEffectやuseLayoutEffectを使用する場合、注意深く扱わないとクロージャが古い値をキャプチャする可能性があります。

問題:

function StaleClosureComponent() {
  const [count, setCount] = useState(0);

  // Compiler memoizes 'logCount' but it captures 'count' from its initial render
  // if 'count' is not explicitly listed as a dependency for the effect.
  const logCount = () => {
    console.log('Current count:', count);
  };

  useEffect(() => {
    // This effect runs once and uses the 'logCount' from the initial render,
    // which always logs 0, even if count updates.
    const interval = setInterval(logCount, 1000);
    return () => clearInterval(interval);
  }, []); // Missing dependency: logCount

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
    </div>
  );
}

修正: useEffectとuseCallback(特定の特殊なケースや外部ライブラリとの互換性のためにまだ使用されている場合)が正しい依存関係を持っていることを確認してください。コンパイラはコンポーネントレベルのメモ化を助けますが、useEffectの依存関係は正しい動作のために依然として重要です。

function CorrectClosureComponent() {
  const [count, setCount] = useState(0);

  // Compiler memoizes 'logCount', and it will be re-created if 'count' changes.
  // This is fine, as the effect will re-run and capture the new 'logCount'.
  const logCount = () => {
    console.log('Current count:', count);
  };

  useEffect(() => {
    // Now, 'logCount' is a dependency. When 'count' changes, 'logCount' is re-created
    // by the compiler, and this effect re-runs, capturing the new 'logCount' with the updated 'count'.
    const interval = setInterval(logCount, 1000);
    return () => clearInterval(interval);
  }, [logCount]); // Correct dependency

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
    </div>
  );
}

ほとんどの場合、countが安定していれば、コンパイラはlogCountを安定させます。ただし、countが変更された場合、logCountは再作成されます。useEffectは、最新の関数でエフェクトが再購読されるように、依然としてlogCountを依存関係として宣言する必要があります。これは、コンパイラが正しいuseEffectの依存関係管理の必要性を排除しないという微妙な点です。

3. サードパーティライブラリとコンテキストコンシューマ

一部のサードパーティライブラリは、コンパイラの仮定と完全に互換性がない場合があります。特に、特定の参照安定性保証に依存したり、ディープ比較を実行したりするライブラリです。同様に、カスタムのコンテキストプロバイダ/コンシューマも慎重なレビューが必要になる場合があります。

修正: 徹底的にテストしてください。予期しない動作に遭遇した場合は、/* @no-optimize */または/* @no-memo */ディレクティブ(正確な構文についてはコンパイラのドキュメントを確認してください)を使用して特定のファイルまたはコンポーネントのコンパイラを一時的に無効にするか、問題のある領域については手動のuseMemo/useCallbackに戻してください。ライブラリのメンテナーに問題を報告してください。

Advertisement

よくある質問

1. ReactコンパイラはuseMemoとuseCallbackのすべての必要性を排除しますか?

いいえ。コンポーネントレベルの最適化における使用を大幅に削減しますが、特殊なケースは存在します。たとえば、独自の内部最適化のために明示的にuseMemo/useCallbackを必要とするサードパーティライブラリにメモ化された値やコールバックを渡す必要がある場合、またはコンパイラが完全に最適化できない非常に複雑な非リアクティブな計算を扱っている場合などです。ただし、一般的なコンポーネントのレンダリングロジックでは、それらはほとんど冗長になります。

2. コンパイラはプロパティとして渡される複雑なオブジェクトや関数をどのように処理しますか?

コンパイラは参照の等価性を追跡します。複雑なオブジェクトや関数がプロパティとして渡され、その参照がレンダリング間で同じままであれば、コンパイラはそれを安定していると見なします。新しいオブジェクトや関数がレンダリングごとに作成される場合(例:onClick={() => doSomething()})、コンパイラはこれを変更として検出し、依存するコンポーネントを再レンダリングします。コンパイラの強みは、依存関係が変更されていない場合に、コンポーネント内で派生した値や関数を自動的にメモ化し、それらの再作成を防ぐことです。

3. Reactコンパイラ自体のパフォーマンスオーバーヘッドはどのくらいですか?

コンパイラはビルド時に実行されるため、コンパイルプロセス自体にランタイムオーバーヘッドはありません。生成されたコードには参照の等価性に関する追加のチェックが含まれており、最適化されていないコードと比較して最小限のランタイムオーバーヘッドが発生します。ただし、このオーバーヘッドは、特に複雑なアプリケーションでは、不要な再レンダリングや再計算を防ぐことによるパフォーマンス向上によって、ほとんどの場合、はるかに小さくなります。

4. 特定のコンポーネントやファイルでコンパイラをオプトアウトできますか?

はい、Reactコンパイラはオプトアウトメカニズムをサポートしています。通常、ファイルまたはコンポーネント関数の先頭に特別なコメントディレクティブ(例:/* @no-optimize */または/* @no-memo */)を追加して、コンパイラにその特定のコードブロックの処理をスキップするように指示できます。これは、デバッグや、コンパイラを有効にしたときに予期しない動作を示すコンポーネントに役立ちます。正確な構文については、公式のReactコンパイラドキュメントを参照してください。

5. ReactコンパイラはNext.jsやRemixのようなサーバーサイドレンダリング(SSR)フレームワークで動作しますか?

はい、ReactコンパイラはSSRフレームワークとシームレスに連携するように設計されています。Babelトランスフォームであるため、ビルドプロセス中に動作し、クライアントサイドとサーバーサイドの両方のバンドルに影響を与えます。これは、自動メモ化によるパフォーマンス上の利点が、初期のサーバーレンダリングされたHTMLだけでなく、その後のクライアントサイドのハイドレーションと更新にも適用されることを意味します。

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement