React Compiler によるパフォーマンスの検証とメモ化戦略を考える
はじめに
React 19 の登場により、React Compiler が導入され、よっしゃもうメモ化のこと考えなくていいのか!と思ったんですが、手動でメモ化を行った場合と、React Compiler に任せた場合とで、パフォーマンスに違いはあるのか?そもそも今現在React18(React Compiler無)で運用しているプロジェクトをReact19にバージョンを上げReactCompilerを導入しようとした際に、既存のメモ化を削除した方が良いのか?など色々疑問が出てきたので気になって調べてみました。
実験に使ったコード
以下の6つのパターンでコンポーネントを作成し、それぞれのパフォーマンスを計測します。
- メモ化なし
useMemo
のみuseCallback
のみuseMemo
とuseCallback
の両方- 複雑な依存関係をもつ
useMemo
- 複雑な依存関係をもつ
useCallback
共通のロジック
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 interfacesja.react.dev
結果
パフォーマンス比較表(単位:ms)
パターン | Compiler無し | Compiler有り(メモ化あり) | Compiler有り(メモ化なし) | |||
---|---|---|---|---|---|---|
初回 | 2-3回目平均 | 初回 | 2-3回目平均 | 初回 | 2-3回目平均 | |
メモ化なし | 1197.90 | 1212.35 | 598.10 | 613.45 | 612.10 | 620.50 |
useMemoのみ | 1193.50 | 0.30 | 1187.70 | 0.40 | 607.60 | 620.30 |
useCallbackのみ | 1193.30 | 1213.30 | 593.90 | 611.95 | 607.80 | 620.65 |
useMemoとuseCallbackの両方 | 1193.20 | 0.35 | 1203.60 | 0.30 | 607.90 | 619.20 |
複雑な依存関係をもつuseMemo | 1192.60 | 0.25 | 1190.70 | 0.35 | 608.60 | 620.80 |
複雑な依存関係をもつuseCallback | 0.30 | 0.10 | 0.20 | 0.15 | 0.30 | 0.05 |
- Compiler 無し、メモ化なしの場合を見るとしっかり最適化されていることがわかった。
- 初回レンダリング時間が約半分に短縮された(例: 1197.90ms → 598.10ms)。
💡 React Compiler は静的解析を行い、依存関係を自動的に分析したり、不要な計算を事前に検出し省いてくれる。例えば、特定の値が変更されないことが確定している場合、その値に関連する計算をスキップできる。また、コンパイル時にコードを変換し、不要な計算を事前に排除する。例えば、静的な値や定数に対してはランタイムでの計算を完全にスキップできる。つまり、手動で useMemo を使用する場合、初回レンダリング時には必ず計算が行われますが、React Compiler はこれを防ぐことができる。
- React Compilerを使用しても依然として再レンダリング時のコストが高い場合がある(複雑な依存関係をもつuseMemo:約620ms)。一方、手動メモ化を使用した場合はほぼゼロコスト(0.1~0.4ms)となった。
React Compiler 導入におけるメモ化戦略
既存プロジェクトの場合
- React Compiler は、手動メモ化されている部分も含めて全体を最適化をするので。そのため、半端なメモ化が原因でパフォーマンスが悪化することはない上に、 既存の
useMemo
やuseCallback
を削除する労力を考えると、そのまま残す方が良いと考えます。
新規実装の場合
- 複雑な依存配列を持つ場合は、引き続き手動で
useMemo
やuseCallback
を使用した方がパフォーマンスは良さそうです。 - 簡単な依存関係の場合は、メモ化を省略して React Compiler に任せましょう。
結論
- 新しいコードでは、React Compiler に全部任せる。
- 既存のコードでは、React Compiler が自動的に最適化を行うので、既存のメモ化コードを削除する必要はない。
- 複雑な依存関係や再レンダリングが頻繁に発生するコンポーネントでは、手動メモ化の方が有用な場合もある。
まとめ
React Compiler は導入するだけでパフォーマンス的にも圧倒的なメリットがありそうです。パフォーマンスが気になってからメモ化する→でも検証する時間はない、そもそもメモ化する対象をどうするか→最初に全部してしまう、など色々チームによって議論や方針があるとLT等でよく聞くので、React Compiler 導入でそんなことも考えなくて良くなりそうです。
参考
React Compiler Beta リリース – ReactThe library for web and native user interfacesja.react.dev
React Compiler – ReactThe library for web and native user interfacesja.react.dev
React 19 Upgrade Guide – ReactThe library for web and native user interfacesreact.dev