Old-School Front-End · Frameworks / 2026 ・ No.08
いにしえのフロントエンド入門 フレームワーク編
第8回 再描画のコスト ── memo、useCallback、React Compiler、そしてブラウザ
公開
第5回で、1文字打つと部品が全部描きなおされることを数えました。第8回は、それがどれだけ時間を使っているかを測り、memo・useCallback・React Compiler 1.0 で、どこまで減るかを比べます。
先に答えを言うと、申請フォームの3行では、どれでも約2ミリ秒で、直す必要はありませんでした。差が出るのは行が100を超えてから。そして1000行では、React を速くしても、ブラウザの描画がそのまま残りました。
直す前に測る
新井1文字で全部描きなおすなんて、もったいないですよね。全部の部品に memo を付けます!
古井付ける前に、いま何ミリ秒かかっているかを言えるか?
全15回の路線図(いまここ:第8回)
# 申請フォーム(明細3行)で、1行目の金額に1文字打つたびに呼ばれた回数(本番用) 部品そのままCompiler(第7回の App)Compiler(直した App) App 1 1 1 AboutSection 1 1 0 ItemsSection 1 1 1 ItemTable 1 1 1 ItemRow 3 3 3 TotalLine 1 1 1 ReceiptSection 1 1 0 SendButtons 1 1 0 JS(1文字)1.95 ms 2.09 ms 1.81 ms # 3行では、どれも 2 ms 前後。人には区別がつかない
第7回の申請フォーム(明細3行)で、1文字打つたびの JavaScript の時間を測ると、約2ミリ秒でした。1秒間に60回画面を描くとすると、1回に使えるのは約16ミリ秒です。その8分の1も使っていません。ここで手を入れても、だれも気づきません。今回は、どこから気づくようになるかを、行を増やして確かめます。
測る道具
const onRender = (id, phase, actualDuration, baseDuration) => …; // 描いた時間(ミリ秒)を記録する
<Profiler id="rows" onRender={onRender}>
<table> … </table>
<TotalLine … />
</Profiler>
React には <Profiler> という部品があり、中が描かれるたびに、かかった時間(actualDuration)を教えてくれます。React Developer Tools の Profiler タブも、同じ仕組みで記録しています。
// 第8回:測るための切りかえ(ふだんは どちらも付けない)
// COMPILER=1 … React Compiler(Babel のプラグイン)を通す
// PROFILE=1 … 本番用のビルドでも <Profiler> の onRender が呼ばれる版の react-dom を使う
const compiler = process.env.COMPILER === '1'
const profile = process.env.PROFILE === '1'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), ...(compiler ? [babel({ presets: [reactCompilerPreset()] })] : [])],
resolve: profile ? { alias: { 'react-dom/client': 'react-dom/profiling' } } : {},
server: { … },
build: {
rolldownOptions: { input: { main: 'index.html', lab: 'lab.html' } }, // 第8回:実験の画面も本番用でビルドする
},
})
ただし、本番用のビルドでは、ふだん Profiler は何もしません。react-dom/profiling という版に差しかえると、本番用でも測れます。開発用で測らないのは、三で見るように、開発用は StrictMode とチェックのために何倍も遅いからです。時間は第3回と同じく、Chromium の記録(tracing)から「JavaScript」と「描画(Layout・Paint など)」に分けて、1文字あたりで出しました。
行を増やす
実験の画面 lab.html?lab=rows では、申請フォームと同じ ItemRow と TotalLine を、10・100・1000 行に増やせます。
# lab.html?lab=rows。1行目の金額に1文字打つたびの時間(本番用、Chromium の記録、3回の中央値) 行の数JS 描画(ブラウザ)ItemRow が呼ばれた 102.34 ms 1.88 ms10 1007.2 ms 10.95 ms100 100047.15 ms 50.92 ms1000 1000(開発用)308.59 ms 54.63 ms2000 # 描画は Layout(配置の計算)と Paint(塗り)。1000 行では1文字ごとに合わせて約 100 ms # 開発用は StrictMode で2回呼び、開発用のチェックも走るので、JS が本番用の約6.5倍
100 行で JS 7ミリ秒、1000 行では 47 ミリ秒。打った行のほかの 999 行も、毎回 ItemRow が呼ばれています。そして見落としやすいのが右の列、ブラウザの描画にも 51 ミリ秒かかっていることです。合わせて約100ミリ秒、打った文字が少し遅れて出るのが、目で見てわかります。
memo だけでは減らない
新井くんの案どおり、ItemRow を memo で包みました。memo は、props が前と同じなら、部品の関数を呼ばずに前の結果を使います。
結果は、②のとおり1行も減りませんでした。むしろ props を比べる手間の分だけ、少し遅くなっています(JS 47 → 52 ミリ秒)。
memo は props を1つずつ比べる
原因は、App が描くたびに作りなおしている関数です。onChange も onRemove も errorOf も、中身は同じでも、毎回新しく作った別の関数です。memo の比べ方は「同じものか」なので、ちがう、と判断されます。
関数を1つに固定する
const MemoRow = memo(ItemRow); // props が前と同じなら、ItemRow の関数を呼ばない
// plain・memo:第7回の App と同じ書き方。描くたびに、新しい関数ができる
const changeEach = (id, name, value) => setRows(rows.map((r) => (r.id === id ? { ...r, [name]: value } : r)));
const removeEach = (id) => setRows(rows.filter((r) => r.id !== id));
const errorOfEach = () => undefined;
// memocb:前の値を受け取る形(prev => …)にすれば rows を使わないので、関数を1つに固定できる
const changeOnce = useCallback((id, name, value) => setRows((prev) => prev.map((r) => (r.id === id ? { ...r, [name]: value } : r))), []);
const removeOnce = useCallback((id) => setRows((prev) => prev.filter((r) => r.id !== id)), []);
useCallback は、関数を1回だけ作って、次の描画でも同じものを返します。ただし、中で rows を使うと、rows が変わるたびに作りなおさないと古い rows を見てしまいます。そこで第5回の④、前の値を受け取る形(setRows((prev) => …))にしました。rows を使わないので、関数はずっと1つで済みます。
# 1000 行で、1行目の金額に1文字打つたび(本番用、3回の中央値) 書き方 JS 描画 React ItemRow ① そのまま47.1 50.9 23.6 1000 ② memo だけ52.0 54.1 26.7 1000 ③ memo + useCallback3.0 51.9 1.5 1 ④ Compiler(①のまま)39.9 48.8 20.2 1000 ⑤ Compiler(prev => だけ)6.9 49.8 5.5 1000 ⑥ Compiler + ③3.1 50.5 1.4 1 ⑦ ③ + 見える行だけ2.6 2.9 1.2 1 # 単位は ms。React は <Profiler> の actualDuration(profiling 版のビルドで別に測った)。ItemRow は呼ばれた回数
③で、呼ばれる ItemRow は1文字につき1つになり、JS は 47 ミリ秒から 3 ミリ秒になりました(React の分は 23.6 → 1.5 ミリ秒)。memo はprops がそろって初めて効く、というのが四と五の答えです。
# 行の数で比べる(JS、1文字あたり ms) 書き方 10 行 100 行 1000 行 ① そのまま2.3 7.2 47.1 ② memo だけ1.8 9.5 52.0 ③ memo + useCallback1.8 1.3 3.0 ④ Compiler(①のまま)2.4 6.1 39.9 ⑥ Compiler + ③1.4 1.3 3.1 # 10 行では、どの書き方も 2 ms 前後。差が見えてくるのは 100 行から
React Compiler に任せる
memo と useCallback を、どこに付けるかを考えるのは手間です。2025年10月に 1.0 になった React Compiler は、ビルドのときに部品を書きかえて、「前と同じなら前の結果を使う」処理を自動で入れます。Vite では、二の設定のように Babel のプラグインとして足しました(COMPILER=1)。
五の表の④〜⑥がその結果です。①のコードのまま通した④は、47 → 40 ミリ秒。少ししか減りません。関数が rows を使っているので、コンパイラーも毎回作りなおすしかないからです。ところが、関数を prev => の形にしただけの⑤(memo も useCallback も書かない)は、7 ミリ秒まで減りました。ItemRow の関数は 1000 回呼ばれていますが、中身が前と同じなので、コンパイラーが覚えておいた結果をすぐ返しています。手で書いた③と組み合わせた⑥は、③とほぼ同じでした。
# npm run build(3回)と、申請フォームの JavaScript(main-*.js)
ビルドの時間main-*.js
Compiler なし1.35〜1.95 s11.91 kB
Compiler あり3.06〜3.48 s15.48 kB + compiler-runtime 0.23 kB
# React 本体などの共通部分(211 kB)は同じ。Compiler は覚えておくための入れ物と比べる処理を足す
代わりに、ビルドの時間は約2倍、申請フォームの JavaScript は 3.6 kB 増えました。
コンパイラーが App を飛ばした
一の表をもう一度見てください。Compiler を通しても、第7回の App のままでは、8つの部品がすべて呼ばれていました。コンパイラーが何をしたかを、部品ごとに表示させます。
// React Compiler が、どの部品を覚える形に直せたか(直せなかったか)を表示する(第8回)
// 使い方:node scripts/rc-log.mjs src/App.jsx src/components/*.jsx
import { transformSync } from '@babel/core';
import fs from 'node:fs';
import path from 'node:path';
for (const file of process.argv.slice(2)) {
const events = [];
transformSync(fs.readFileSync(file, 'utf8'), {
filename: file, babelrc: false, configFile: false, parserOpts: { plugins: ['jsx'] },
plugins: [['babel-plugin-react-compiler', { logger: { logEvent: (_, e) => events.push(e) } }]],
});
for (const e of events) {
if (e.kind === 'CompileSuccess') console.log(`${path.basename(file).padEnd(20)}${e.kind.padEnd(16)}${e.fnName} slots=${e.memoSlots}`);
else console.log(`${path.basename(file).padEnd(20)}${e.kind}\n └ ${e.detail?.reason ?? e.detail?.options?.reason ?? ''}`); // 直せなかった理由
}
}
第7回の App.jsx は CompileError で、そのまま(何も覚えない形で)ビルドされていました。理由は「try/catch の中の条件式(?? など)にはまだ対応していない」。第7回の submitClaim で、401 を受けるために書いた try/catch です。ビルドは成功し、警告も出ないので、こうして調べないかぎり気づきません。
/** Spring Boot へ送る。ログインが切れていたら null(第8回:try/catch を部品の外へ出した。React Compiler が App を飛ばさないように) */
async function trySend(body, files) {
try {
return await sendClaim(body, files);
} catch (e) {
if (e instanceof SignedOut) return null;
throw e;
}
}
// App の中
// 第8回:前の値(prev)を受け取る形にした。rows を使わないので、React Compiler が関数を1つに固定できる
const change = (id, name, value) => setRows((prev) => prev.map((r) => (r.id === id ? { ...r, [name]: value } : r)));
const add = () => {
const row = newRow(); // 新しい行は外で作る(prev => … の中は、2回呼ばれても同じ結果になるように)
setRows((prev) => [...prev, row]);
};
const remove = (id) => {
const empty = newRow();
setRows((prev) => {
const rest = prev.filter((r) => r.id !== id);
return rest.length > 0 ? rest : [empty];
});
};
try/catch を部品の外の関数 trySend へ出すと、App も CompileSuccess になり、一の表の右の列のとおり、件名や領収書の部品は呼ばれなくなりました。あわせて、change・add・remove も prev => の形にしました。新しい行は外で作ってから渡しています。prev => … の中は、StrictMode で2回呼ばれても同じ結果になる必要があるからです。
残ったのはブラウザの分
1000 行で1文字打つたびの時間
memo でも Compiler でも、減ったのは赤い部分(JS)だけで、灰色の描画は約 50 ミリ秒のまま残りました。記録を見ると、Layout が 22 ミリ秒、Paint が 20 ミリ秒。1000 行の表の中で1つの欄の文字が変わると、ブラウザは表全体の配置を計算しなおし、塗りなおします(table-layout: fixed や高さを決めた箱に入れても、ほとんど変わりませんでした)。
減らすには、ブラウザに渡す行そのものを減らすしかありません。⑦は、高さ 600px の箱の中で、見えている 20 行だけを描き、上下はその高さの空の行で埋めたものです(一覧の画面でよく使う「仮想スクロール」の考え方)。1文字あたり 98 → 5 ミリ秒になりました。
# 合計の計算(calcTotal)だけを Node.js で 2000 回測った1回あたり 10 行9.2 µs 100 行50.0 µs 1000 行453.6 µs # 1000 行でも 0.45 ms。1文字の 100 ms に比べれば、useMemo で覚える意味はない
ついでに、合計の計算を useMemo で覚えるべきかも測りました。1000 行でも 0.45 ミリ秒です。useMemo は「重い計算だと測ってから」で十分です。
このアプリでは
- 手で memo・useCallback は付けない ── 明細は最大10行。10 行ではどの書き方も約2ミリ秒(五の2つ目の表)
- コンパイラーに飛ばされない書き方にはしておく ── try/catch を部品の外へ、状態は prev => で。どちらも害がなく、読みやすさも変わらない
- Compiler はまだ常には入れない ── ビルドが約2倍になるのに、3行では差が出ない。COMPILER=1 で切りかえられるようにだけしておき、第15回で公開の形を決めるときにもう一度考える
- 行が増える画面(申請一覧など)を作るときは、まず描く行を減らす ── ページを分けるか、見えている行だけ描く
自分の PC で
React Developer Tools の Profiler タブで「録画」を押し、1行目の金額に数文字打ってから止めると、部品ごとの時間が棒で出ます。歯車の設定で「Record why each component rendered while profiling.」を有効にすると、「Props changed: (onChange)」のように、描きなおした理由も出ます。四の memo だけのときに見ると、原因の関数の名前がそのまま出ます。
本番用で測るなら PROFILE=1 npm run build と npm run preview、Compiler を試すなら COMPILER=1 を足します。npm run rc-log で、コンパイラーが部品をどう扱ったかを確かめられます。
結 — 一枚まとめと、次回
- 直す前に本番用で測る。申請フォームの3行は約2ミリ秒で、直す必要はなかった。開発用は約6.5倍遅い。
- memo だけでは減らない。描くたびに新しい関数を渡していると、props は毎回ちがう。
- useCallback と prev => で関数を固定すると、1000 行で JS 47 → 3 ミリ秒。
- React Compiler は prev の形にするだけで 47 → 7 ミリ秒。ただ、try/catch のある App を黙って飛ばしていた。
- React を速くしてもブラウザの描画(約50ミリ秒)は残る。減らすには、描く行を減らす。
新井memo を全部に付けなくてよかった……。ところで、ログインした人の名前を、ヘッダーにも出したいんですけど。
古井App から props で何段も渡すことになるな。次回は状態の置き場所。Context と useReducer で、承認の状態の移り変わりも画面に持たせる。
参考
- React — memo
- React — useCallback
- React — useMemo
- React — <Profiler>
- React — React Compiler
- React Blog — React Compiler v1.0(2025-10-07)
- @vitejs/plugin-react — React Compiler
- web.dev — Rendering performance
記録は、筆者がクラウド上の Linux で、2026年11月3日に取ったものです。第7回のプロジェクト(Vite 8.3.5、React 19.3.1)に、実験の画面と、babel-plugin-react-compiler 1.0.0(@rolldown/plugin-babel 0.2.4、@babel/core 8.1.0)を足しました。本番用のビルドを npm run preview で配り、Playwright で動かした Chromium 153.0.8010.12 で開いて、1文字ずつ input の出来事を送りました。時間は Chromium の記録(devtools.timeline)から1文字あたりを出し、3回の中央値を取っています。申請フォームの測定では、バックエンド編の Spring Boot につないでいます。
いにしえのフロントエンド入門 フレームワーク編 第8回 — 再描画のコスト / 2026.11