Old-School Front-End · Frameworks / 2026 ・ No.08

いにしえのフロントエンド入門 フレームワーク編

第8回 再描画のコスト ── memo、useCallback、React Compiler、そしてブラウザ

公開

ブラウザの画面。明細 n=1000 how=memocb。1行目の金額は 1472012345 で、右に赤い 5。2行目から下はどれも灰色の 0
1000 行のうち、描きなおされたのは打った1行だけ。memo と useCallback を組み合わせたとき(筆者がクラウド上の Linux で、2026年11月3日に撮影。各行の右の数字は、その行の ItemRow が呼ばれた回数を写真のために書き足したもの。ウインドウの上の帯も説明のために描き足した)

第5回で、1文字打つと部品が全部描きなおされることを数えました。第8回は、それがどれだけ時間を使っているかを測り、memo・useCallback・React Compiler 1.0 で、どこまで減るかを比べます。
先に答えを言うと、申請フォームの3行では、どれでも約2ミリ秒で、直す必要はありませんでした。差が出るのは行が100を超えてから。そして1000行では、React を速くしても、ブラウザの描画がそのまま残りました。

一

直す前に測る

新井1文字で全部描きなおすなんて、もったいないですよね。全部の部品に memo を付けます!

古井付ける前に、いま何ミリ秒かかっているかを言えるか?

全15回の路線図(いまここ:第8回)

1 なぜ FW 2 jQuery→MVC 3 React 以後 4 React の一歩 5 状態と再描画 6 フォーム 7 データ取得 8 再描画のコスト 9 状態の置き場所 10 画面遷移 11 楽観的更新 12 Server Comp. 13 キャッシュ 14 テスト 15 公開と選び方 いまここ なぜと歴史 React の基本 速さと置き場所 画面とサーバー Next.js 仕上げ
# 申請フォーム(明細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 が前と同じなら、部品の関数を呼ばずに前の結果を使います。

3つの小さなブラウザの画面。どれも 1000 行の明細で、1行目の金額に5文字打ったあと。① そのままは、どの行の右にも 5。② memo だけも、どの行も 5。③ memo と useCallback は、1行目だけ 5 で、ほかは 0
memo を付けただけでは1行も減りませんでした(筆者がクラウド上の Linux で、2026年11月3日に撮影。各行の右の数字は、その行の ItemRow が呼ばれた回数を写真のために書き足したもの。ウインドウの上の帯も説明のために描き足した)

結果は、②のとおり1行も減りませんでした。むしろ props を比べる手間の分だけ、少し遅くなっています(JS 47 → 52 ミリ秒)。

memo は props を1つずつ比べる

② memo だけ 前の props 今の props row row = n n = errorOf errorOf ≠ onChange onChange ≠ onRemove onRemove ≠ 1つでもちがう → 描く(1000 行とも) ③ memo + useCallback 前の props 今の props row row = n n = errorOf errorOf = onChange onChange = onRemove onRemove = 全部同じ → 呼ばない 比べ方は「同じものか」(Object.is)。中身が同じでも、新しく作った関数はちがうもの 関数を1つに固定するのが useCallback。prev => … の形なら固定できる

原因は、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 ?? ''}`); // 直せなかった理由
  }
}
端末の画面。node scripts/rc-log.mjs before/App.jsx の結果は App.jsx CompileError、理由は Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement。try/catch を部品の外へ出したあとの src/App.jsx と部品は、App slots=73、ItemRow slots=72 など、すべて CompileSuccess
第7回の App.jsx は、React Compiler に黙って飛ばされていました(筆者がクラウド上の Linux で、2026年11月3日に撮影。各行の右の数字は、その行の ItemRow が呼ばれた回数を写真のために書き足したもの。ウインドウの上の帯も説明のために描き足した)

第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文字打つたびの時間

JS(React と部品) 描画(ブラウザの Layout・Paint) ① そのまま 98 ms ② memo だけ 106 ms ③ memo + useCallback 55 ms ④ Compiler(①のまま) 89 ms ⑤ Compiler(prev =>) 57 ms ⑥ Compiler + ③ 54 ms ⑦ ③ + 見える行だけ 5 ms memo や Compiler で減るのは赤(JS)だけ。灰色(描画)は 1000 行あるかぎり残る 灰色を減らすには、ブラウザに描かせる行そのものを減らす(⑦:見えている 20 行だけ) 本番用のビルド、Chromium の記録から1文字あたり(3回の中央値)

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 で、コンパイラーが部品をどう扱ったかを確かめられます。

結 — 一枚まとめと、次回

  1. 直す前に本番用で測る。申請フォームの3行は約2ミリ秒で、直す必要はなかった。開発用は約6.5倍遅い。
  2. memo だけでは減らない。描くたびに新しい関数を渡していると、props は毎回ちがう。
  3. useCallback と prev => で関数を固定すると、1000 行で JS 47 → 3 ミリ秒。
  4. React Compiler は prev の形にするだけで 47 → 7 ミリ秒。ただ、try/catch のある App を黙って飛ばしていた。
  5. React を速くしてもブラウザの描画(約50ミリ秒)は残る。減らすには、描く行を減らす。

新井memo を全部に付けなくてよかった……。ところで、ログインした人の名前を、ヘッダーにも出したいんですけど。

古井App から props で何段も渡すことになるな。次回は状態の置き場所。Context と useReducer で、承認の状態の移り変わりも画面に持たせる。

参考

記録は、筆者がクラウド上の 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

← 記事一覧へ