軽い式をuseMemoで囲んだら、計算回数は減っても、画面更新全体は遅くなりました。今回の小さなベンチマークでは、2,000個の要素を100回更新した中央値が、直接計算29.7ms、useMemoあり34.5msでした。
一方、実プロジェクトから取り出した日程の整形処理では、依存が変わらない更新の再計算を省けました。メモ化を入れるかは、そこで省けた処理を確認して判断します。
useCallbackも、関数を渡す相手が描画を省けるかで結果が変わりました。この記事では、同じ出力になるケースの計算回数と、同期更新の所要時間を比べます。
検証環境と、測っていない時間
2026年9月11日に、次の環境で実行しました。
- React / React DOM 19.2.5、esbuild 0.27.4、Node.js 24.15.0
- Apple M4 Max、macOS 26.6.2
- Playwright CLI 0.1.19が起動したHeadlessChrome 152.0.0.0(User-Agent表記)
- productionビルド、React Compilerなし、CPUスロットリングなし
各ケースは20回のウォームアップ後、flushSyncを使ったrootの更新100回をperformance.now()で測ります。9ラウンド実行し、ケースの順序をラウンドごとにずらしました。各ケースの開始時にrootを作り、終了時に破棄しています。
測定区間にはReactの更新とDOMへの反映が含まれます。描画後のpaint、ユーザー入力からの遅延、通信、クリップボードへのコピーは含めていません。計算回数を数える変数も実験用に入れています。アプリ全体の応答性能や実ユーザーの端末を測った数字ではありません。
ReactのuseMemo公式説明も、本番ビルドと利用者に近い端末での計測を案内しています。今回の高性能な開発機で出た差を、そのままモバイル端末へ当てはめません。
軽い式では、計算を省く費用のほうが大きかった
比較したのは、同じ値に対するvalue * 2 + 1です。直接計算するコンポーネントと、次のメモ化版をそれぞれ2,000個並べました。コンポーネント自体をmemoで囲んではいません。
function CheapMemo({ value }: { value: number }) {
const result = useMemo(() => {
counters.calculations++;
return value * 2 + 1;
}, [value]);
return <span>{result}</span>;
}
valueは全更新で21です。ウォームアップ後の測定区間では、直接版は200,000回計算し、メモ化版は0回でした。どちらのDOMにも43が並びます。
| 方式 | 100回更新の中央値 | 9ラウンドの最小〜最大 | 測定区間の計算回数 |
|---|---|---|---|
| 直接計算 | 29.7ms | 29.1〜33.4ms | 200,000 |
| useMemo | 34.5ms | 32.9〜39.5ms | 0 |
中央値ではメモ化版のほうが遅くなりましたが、最小〜最大の範囲は一部重なっています。ただし、差の4.8msは2,000個を100回更新した合計の中央値の差です。一つのHookが常に一定時間遅い、という測定ではありません。
依存の比較や値の保持を含む処理を追加して、非常に軽い算術を省いています。これだけ小さい計算なら、Hookを増やす前に直接書いた状態を測る理由があります。
日程整形では、依存の安定性で結果が変わった
SquadNoteには、日程を月で絞り、施設ごとにまとめ、日時順に並べて共有用の文章を作るformatMonthlySummaryがあります。確認した固定ソースを変更せず取り出しました。
呼び出し元の月次まとめ画面では、日程などが同じままコピー済み表示やダイアログの状態が変わります。今回のベンチはこの「整形に無関係な更新」を切り出しています。画面全体をコピーしたものではありません。
入力には説明用の日程1,000件を生成しました。実際の利用件数ではありません。日付、施設、中止フラグを固定し、出力した文章の長さをDOMへ表示します。
| 方式 | 100回更新の中央値 | 整形関数の呼び出し回数 |
|---|---|---|
| 毎回直接呼ぶ | 48.9ms | 100 |
| 安定した日程配列を依存にする | 0.1ms | 0 |
| 毎回作るoptionsオブジェクトを依存にする | 49.5ms | 100 |
毎回新しいoptionsを作るケースでは、配列の内容が同じでもメモ化が効きませんでした。
function UnstableSummary() {
const options = { schedules };
const summary = useMemo(() => calculate(), [options]);
return <output>{summary.length}</output>;
}
ここでのcalculate()は、同じ固定入力で元の整形関数を呼び、回数を記録するラッパーです。実装例を一般化するなら、関数が読む反応的な値を依存から省くのではなく、新しいオブジェクトを毎回依存へ渡す必要があるかを見直します。
安定したケースの0回は、最初から計算していない意味ではありません。初回の計算はウォームアップ前半に含まれています。初回表示や日程変更まで速くしたという結果ではありません。
useCallbackだけでは、子の描画は止まらない
同じ整形処理を子へ移し、コールバックを渡す方法を比較しました。親の更新回数と入力は同じです。
| 子とpropsの組合せ | 100回更新の中央値 | 子の描画回数 |
|---|---|---|
| memoの子へインライン関数 | 49.4ms | 100 |
| memoの子へuseCallback、他のpropsも安定 | 0.0ms(測定分解能未満) | 0 |
| 通常の子へuseCallback | 49.1ms | 100 |
| memoの子へuseCallbackと毎回新しいオブジェクト | 49.1ms | 100 |
0.0msは処理時間が存在しない意味ではありません。この短い処理を今回のタイマーでは分離できなかったため、倍率の計算にも使いません。
useCallbackの公式説明では、キャッシュするのは関数の定義であり、呼び出した結果ではありません。また、関数自体を新しく作ることを防ぐ機能でもありません。今回減ったのは、安定したpropsを受け取ったmemoの子が実行する整形処理です。
通常の子へ渡すだけなら描画は続きます。子をmemoにしても、別のpropsが毎回新しいオブジェクトなら、描画を省けませんでした。コールバックだけ囲んでも、この二つのケースでは重い整形処理が残りました。
元の画面へ戻して判断する
全ラウンドで計算回数を確認し、同じ比較グループのDOM出力が一致することも確認しました。実行用スクリプトは、固定コミットの整形関数と指定バージョンの依存を使います。
この実験だけで、既存画面のuseMemoを外したり、すべての関数にuseCallbackを付けたりする判断はできません。日程が毎回更新される画面ではキャッシュを再利用する機会が減ります。React Compilerを有効にしたアプリでは生成コードも違うため、今回の比較対象から外しています。
変更前後で、どの操作が何回の再計算を起こしたかを確認します。省ける処理が小さいなら、そのまま書く。重い処理が同じ入力で繰り返されるなら、依存を揃えて測り直す。子の描画を省きたいなら、コールバック以外のpropsも確認する。今回の数字は、その確認を行う場所を選ぶためのものです。