Vinyl

React Compiler によるパフォーマンスの検証とメモ化戦略を考える

はじめに

React 19 の登場により、React Compiler が導入され、よっしゃもうメモ化のこと考えなくていいのか!と思ったんですが、手動でメモ化を行った場合と、React Compiler に任せた場合とで、パフォーマンスに違いはあるのか?そもそも今現在React18(React Compiler無)で運用しているプロジェクトをReact19にバージョンを上げReactCompilerを導入しようとした際に、既存のメモ化を削除した方が良いのか?など色々疑問が出てきたので気になって調べてみました。

実験に使ったコード

以下の6つのパターンでコンポーネントを作成し、それぞれのパフォーマンスを計測します。

  1. メモ化なし
  2. useMemo のみ
  3. useCallback のみ
  4. useMemo useCallback の両方
  5. 複雑な依存関係をもつuseMemo
  6. 複雑な依存関係をもつuseCallback

GitHub - mdkk11/react-compilerContribute to mdkk11/react-compiler development by creating an account on GitHub.favicon icongithub.com

共通のロジック

export function heavyCalculation(num: number) {
  let result = 0;
  for (let i = 0; i < 70000000; i++) {
    result += num * Math.random();
  }
  return result.toFixed(2);
}
 
export function renderComponent({ result, count, onClick }: { result: string; count: number; onClick: React.ComponentProps<'button'>['onClick'] }) {
  return (
    <div>
      <p>Result: {result}</p>
      <p>Count: {count}</p>
      <button onClick={onClick}>Increment Count</button>
    </div>
  );
}

各コンポーネント

メモ化なし

const WithoutMemoization = ({ num }) => {
  const [count, setCount] = useState(0)
  const result = heavyCalculation(num)
  const handleClick = () => setCount(count + 1)
 
  return renderComponent(result, count, handleClick)
}

useMemo のみ

const WithUseMemoOnly = ({ num }) => {
  const [count, setCount] = useState(0)
  const result = useMemo(() => heavyCalculation(num), [num])
  const handleClick = () => setCount(count + 1)
 
  return renderComponent(result, count, handleClick)
}

useCallback のみ

const WithUseCallbackOnly = ({ num }) => {
  const [count, setCount] = useState(0)
  const result = heavyCalculation(num)
  const handleClick = useCallback(() => setCount((prev) => prev + 1), [])
 
  return renderComponent(result, count, handleClick)
}

useMemo useCallback の両方

const WithMemoization = ({ num }) => {
  const [count, setCount] = useState(0)
  const result = useMemo(() => heavyCalculation(num), [num])
  const handleClick = useCallback(() => setCount((prev) => prev + 1), [])
  
  return renderComponent(result, count, handleClick)
}

複雑な依存関係をもつuseMemo

export const WithComplexUseMemo = ({ num, data1, data2, data3, data4 }: { num: number; data1: { nested: { value: number } }; data2: { nested: { value: number } }; data3: { nested: { value: number } }; data4: { nested: { value: number } } }) => {
  const [count, setCount] = React.useState(0);
 
  const result = React.useMemo(() => {
    console.log('Recalculating with complex dependencies');
    return heavyCalculation(num + data1.nested.value + data2.nested.value + data3.nested.value + data4.nested.value);
  }, [num, data1.nested.value, data2.nested.value, data3.nested.value, data4.nested.value]);
  const handleClick = () => setCount(count + 1);
 
  return renderComponent({ result, count, onClick: handleClick });
};

複雑な依存関係をもつuseCallback

export const WithComplexUseCallback = ({ num, data1, data2, data3, data4 }: { num: number; data1: { nested: { value: number } }; data2: { nested: { value: number } }; data3: { nested: { value: number } }; data4: { nested: { value: number } } }) => {
  const [count, setCount] = React.useState(0);
  const [result, setResult] = React.useState('');
 
  const handleClick = React.useCallback(() => {
    setResult(heavyCalculation(num + data1.nested.value + data2.nested.value + data3.nested.value + data4.nested.value));
    setCount((prev) => prev + 1);
  }, [num, data1.nested.value, data2.nested.value, data3.nested.value, data4.nested.value]);
 
  return renderComponent({ result, count, onClick: handleClick });
};

アプリケーション全体

const App = () => {
  return (
    <div>
      <React.Profiler
        id="WithoutMemoization"
        onRender={(id, _, actualDuration) => {
          console.log(`${id} のレンダリング時間: ${actualDuration.toFixed(2)}ms`);
        }}
      >
        <h2>Without both useMemo and useCallback</h2>
        <WithoutMemoization num={10} />
      </React.Profiler>
 
      <React.Profiler
        id="WithUseMemoOnly"
        onRender={(id, _, actualDuration) => {
          console.log(`${id} のレンダリング時間: ${actualDuration.toFixed(2)}ms`);
        }}
      >
        <h2>With useMemo only</h2>
        <WithUseMemoOnly num={10} />
      </React.Profiler>
 
      <React.Profiler
        id="WithUseCallbackOnly"
        onRender={(id, _, actualDuration) => {
          console.log(`${id} のレンダリング時間: ${actualDuration.toFixed(2)}ms`);
        }}
      >
        <h2>With useCallback only</h2>
        <WithUseCallbackOnly num={10} />
      </React.Profiler>
 
      <React.Profiler
        id="WithMemoization"
        onRender={(id, _, actualDuration) => {
          console.log(`${id} のレンダリング時間: ${actualDuration.toFixed(2)}ms`);
        }}
      >
        <h2>With both useMemo and useCallback</h2>
        <WithMemoization num={10} />
      </React.Profiler>
 
      <React.Profiler
        id="WithComplexUseMemo"
        onRender={(id, _, actualDuration) => {
          console.log(`${id} のレンダリング時間: ${actualDuration.toFixed(2)}ms`);
        }}
      >
        <h2>With Complex useMemo</h2>
        <WithComplexUseMemo num={50} data1={{ nested: { value: 5 } }} data2={{ nested: { value: 8 } }} data3={{ nested: { value: 9 } }} data4={{ nested: { value: 11 } }} />
      </React.Profiler>
 
      <React.Profiler
        id="WithComplexUseCallback"
        onRender={(id, _, actualDuration) => {
          console.log(`${id} のレンダリング時間: ${actualDuration.toFixed(2)}ms`);
        }}
      >
        <h2>With Complex useCallback</h2>
        <WithComplexUseCallback num={50} data1={{ nested: { value: 5 } }} data2={{ nested: { value: 8 } }} data3={{ nested: { value: 9 } }} data4={{ nested: { value: 11 } }} />
      </React.Profiler>
    </div>
  );
};
 
export default App;

計測方法

React Profilerを使用して、各コンポーネントのレンダリング時間を計測します。onRender コールバックで、以下の情報を取得できるので、actualDurationを使ってレンダリング時間を元に計測しています。

  • id: プロファイリング対象のコンポーネント ID。
  • phase: レンダリングフェーズ(mount または update)。
  • actualDuration: 実際のレンダリング時間(ミリ秒)。

<Profiler> – ReactThe library for web and native user interfacesfavicon iconja.react.dev

結果

パフォーマンス比較表(単位:ms)

パターンCompiler無しCompiler有り(メモ化あり)Compiler有り(メモ化なし)
初回2-3回目平均初回2-3回目平均初回2-3回目平均
メモ化なし1197.901212.35598.10613.45612.10620.50
useMemoのみ1193.500.301187.700.40607.60620.30
useCallbackのみ1193.301213.30593.90611.95607.80620.65
useMemoとuseCallbackの両方1193.200.351203.600.30607.90619.20
複雑な依存関係をもつuseMemo1192.600.251190.700.35608.60620.80
複雑な依存関係をもつuseCallback0.300.100.200.150.300.05
  • Compiler 無し、メモ化なしの場合を見るとしっかり最適化されていることがわかった。
  • 初回レンダリング時間が約半分に短縮された(例: 1197.90ms → 598.10ms)。

💡 React Compiler は静的解析を行い、依存関係を自動的に分析したり、不要な計算を事前に検出し省いてくれる。例えば、特定の値が変更されないことが確定している場合、その値に関連する計算をスキップできる。また、コンパイル時にコードを変換し、不要な計算を事前に排除する。例えば、静的な値や定数に対してはランタイムでの計算を完全にスキップできる。つまり、手動で useMemo を使用する場合、初回レンダリング時には必ず計算が行われますが、React Compiler はこれを防ぐことができる。

  • React Compilerを使用しても依然として再レンダリング時のコストが高い場合がある(複雑な依存関係をもつuseMemo:約620ms)。一方、手動メモ化を使用した場合はほぼゼロコスト(0.1~0.4ms)となった。

React Compiler 導入におけるメモ化戦略

既存プロジェクトの場合

  • React Compiler は、手動メモ化されている部分も含めて全体を最適化をするので。そのため、半端なメモ化が原因でパフォーマンスが悪化することはない上に、 既存の useMemouseCallback を削除する労力を考えると、そのまま残す方が良いと考えます。

新規実装の場合

  • 複雑な依存配列を持つ場合は、引き続き手動で useMemouseCallback を使用した方がパフォーマンスは良さそうです。
  • 簡単な依存関係の場合は、メモ化を省略して React Compiler に任せましょう。

結論

  • 新しいコードでは、React Compiler に全部任せる。
  • 既存のコードでは、React Compiler が自動的に最適化を行うので、既存のメモ化コードを削除する必要はない。
  • 複雑な依存関係や再レンダリングが頻繁に発生するコンポーネントでは、手動メモ化の方が有用な場合もある。

まとめ

React Compiler は導入するだけでパフォーマンス的にも圧倒的なメリットがありそうです。パフォーマンスが気になってからメモ化する→でも検証する時間はない、そもそもメモ化する対象をどうするか→最初に全部してしまう、など色々チームによって議論や方針があるとLT等でよく聞くので、React Compiler 導入でそんなことも考えなくて良くなりそうです。

参考

React Compiler Beta リリース – ReactThe library for web and native user interfacesfavicon iconja.react.dev

React Compiler – ReactThe library for web and native user interfacesfavicon iconja.react.dev

React 19 Upgrade Guide – ReactThe library for web and native user interfacesfavicon iconreact.dev