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

Old-School Front-End: Frameworks

Part 8: The cost of re-rendering — memo, useCallback, React Compiler, and the browser

Published

A browser showing line items, n=1000 how=memocb. Row 1's amount is 1472012345 with a red 5 to its right; every row below shows a grey 0
Of 1000 rows, only the one being typed in re-rendered, with memo and useCallback together (Taken by the author on a cloud Linux machine on 3 November 2026. The number to the right of each row is how many times that row's ItemRow ran, added for the photo; the bar above each window was also drawn in.)

In part 5 we counted that one keystroke re-renders every component. Part 8 measures how much time that actually takes and compares how far memo, useCallback and React Compiler 1.0 bring it down.
The short answer: with the claim form's three rows, everything takes about 2 ms and nothing needs fixing. Differences appear past 100 rows. And at 1000 rows, making React faster left the browser's drawing time untouched.

1

Measure before fixing

AraiRe-rendering everything for one keystroke is wasteful. I'll put memo on every component!

FuruiBefore you do, can you say how many milliseconds it takes now?

The 15-part route map (you are here: part 8)

1 Why FWs 2 jQuery→MVC 3 After React 4 First React 5 State 6 Forms 7 Fetching 8 Render cost 9 Where state 10 Routing 11 Optimistic 12 Server Comp. 13 Caching 14 Testing 15 Ship & choose You are here Why & history React basics Speed & state Pages & server Next.js Finishing
# Calls per keystroke in row 1's amount, claim form with three lines (production)
  component         as is                     Compiler (part 7 App)     Compiler (fixed 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 per key        1.95 ms                   2.09 ms                   1.81 ms
# With three rows it's about 2 ms either way; nobody could tell

Measured on part 7's claim form (three line items), each keystroke costs about 2 ms of JavaScript. At 60 frames a second, each frame has about 16 ms to spend; we use an eighth of it. Change anything here and nobody will notice. This time we add rows to find where people start to notice.

2

The measuring tools

const onRender = (id, phase, actualDuration, baseDuration) => …; // record the render time (ms)

<Profiler id="rows" onRender={onRender}>
  <table> … </table>
  <TotalLine … />
</Profiler>

React has a <Profiler> component that reports how long its contents took to render (actualDuration) each time. The Profiler tab in React Developer Tools records the same way.

// Part 8: switches for measuring (normally neither is set)
//   COMPILER=1 … run React Compiler (the Babel plugin)
//   PROFILE=1  … use the react-dom build that calls <Profiler>'s onRender even in production
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' } }, // Part 8: build the experiment pages for production too
  },
})

In a production build, though, Profiler normally does nothing. Swap in react-dom/profiling and you can measure production too. We don't measure in dev because, as section 3 shows, dev is several times slower thanks to StrictMode and its checks. As in part 3, times come from a Chromium trace, split into "JavaScript" and "drawing" (Layout, Paint and so on), per keystroke.

3

More rows

The experiment page lab.html?lab=rows uses the claim form's own ItemRow and TotalLine, at 10, 100 or 1000 rows.

# lab.html?lab=rows: time per keystroke in row 1's amount (production, Chromium trace, median of 3)
  rowsJS          drawing (browser)ItemRow calls
  102.34 ms     1.88 ms10
  1007.2 ms      10.95 ms100
  100047.15 ms    50.92 ms1000
  1000 (dev)308.59 ms   54.63 ms2000
# Drawing is Layout and Paint. At 1000 rows the total is about 100 ms per keystroke
# Dev calls everything twice (StrictMode) and runs dev checks: about 6.5× production's JS

At 100 rows, 7 ms of JS; at 1000, 47 ms. All 999 rows you aren't typing in still call ItemRow every time. Easy to miss is the right-hand column: the browser's drawing takes another 51 ms. About 100 ms in all, and you can see the typed characters lag behind.

4

memo alone saves nothing

Following Arai's plan, ItemRow went into memo. memo reuses the last result without calling the component when its props are the same as before.

Three small browser windows, each 1000 line items after typing five characters into row 1's amount. ① As is: 5 next to every row. ② memo alone: still 5 on every row. ③ memo plus useCallback: 5 on row 1 only, 0 elsewhere
Adding memo alone didn't save a single row (Taken by the author on a cloud Linux machine on 3 November 2026. The number to the right of each row is how many times that row's ItemRow ran, added for the photo; the bar above each window was also drawn in.)

As ② shows, not a single row was saved. It's even a little slower, from comparing props (JS 47 → 52 ms).

memo compares props one by one

② memo alone previous props new props row row = n n = errorOf errorOf ≠ onChange onChange ≠ onRemove onRemove ≠ one differs → render all ③ memo + useCallback previous props new props row row = n n = errorOf errorOf = onChange onChange = onRemove onRemove = all match → not called Compared with Object.is: a newly made function never matches useCallback keeps one function; prev => … needs no rows

The culprit is the functions App recreates on every render. onChange, onRemove and errorOf do the same thing each time, but each render makes a new, different function. memo asks "is it the same object?", so the answer is no.

5

Keeping functions the same

const MemoRow = memo(ItemRow); // if the props match last time's, don't call ItemRow at all

  // plain, memo: written like part 7's App. Every render makes new functions
  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: take the previous value (prev => …), no rows needed, so the function can stay the same one
  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 makes a function once and hands back the same one on later renders. But if it uses rows inside, it has to be remade whenever rows changes, or it sees stale rows. So we switch to part 5's option ④, taking the previous value (setRows((prev) => …)). With no rows inside, one function is enough for good.

# 1000 rows, per keystroke in row 1's amount (production, median of 3)
  how                           JS        drawing   React     ItemRow
  ① as is47.1      50.9      23.6      1000
  ② memo alone52.0      54.1      26.7      1000
  ③ memo + useCallback3.0       51.9      1.5       1
  ④ Compiler (code ①)39.9      48.8      20.2      1000
  ⑤ Compiler (prev => only)6.9       49.8      5.5       1000
  ⑥ Compiler + ③3.1       50.5      1.4       1
  ⑦ ③ + visible rows only2.6       2.9       1.2       1
# Units: ms. React is <Profiler>'s actualDuration (measured separately on a profiling build). ItemRow is the number of calls

With ③, one ItemRow is called per keystroke and JS falls from 47 ms to 3 ms (React's share from 23.6 to 1.5 ms). The answer of sections 4 and 5: memo only works once all the props line up.

# By number of rows (JS, ms per keystroke)
  how                           10 rows   100 rows  1000 rows
  ① as is2.3       7.2       47.1
  ② memo alone1.8       9.5       52.0
  ③ memo + useCallback1.8       1.3       3.0
  ④ Compiler (code ①)2.4       6.1       39.9
  ⑥ Compiler + ③1.4       1.3       3.1
# At 10 rows everything is about 2 ms; differences show from 100 rows
6

Leaving it to React Compiler

Deciding where to put memo and useCallback is tedious. React Compiler, 1.0 since October 2025, rewrites components at build time and adds "if it's the same as last time, reuse the last result" for you. In Vite it goes in as a Babel plugin, as in section 2's config (COMPILER=1).

Rows ④ to ⑥ of section 5's table are the results. ④, code ① passed through unchanged, went from 47 to 40 ms: only a little, because the functions use rows and the compiler has to remake them each time too. But ⑤, where the functions merely take the prev => form (no memo, no useCallback), fell to 7 ms. ItemRow is still called 1000 times, but its inputs haven't changed, so the compiled code returns the remembered result at once. ⑥, combined with the hand-written ③, was about the same as ③.

# npm run build (3 runs) and the claim form's JavaScript (main-*.js)
                    build time              main-*.js
  no Compiler       1.35〜1.95 s11.91 kB
  with Compiler     3.06〜3.48 s15.48 kB  + compiler-runtime 0.23 kB
# The shared part (React and co., 211 kB) is the same; the Compiler adds memo caches and comparisons

The price: builds take about twice as long, and the claim form's JavaScript grows by 3.6 kB.

7

The compiler skipped App

Look at section 1's table again. Even through the Compiler, part 7's App still had all eight components called. Let's have the compiler report what it did, component by component.

// Show which components React Compiler could rewrite to remember things (and which it couldn't) (part 8)
// usage: 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 ?? ''}`); // why it couldn't
  }
}
A terminal. node scripts/rc-log.mjs before/App.jsx reports App.jsx CompileError, reason: Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement. After moving try/catch out of the component, src/App.jsx and the components all report CompileSuccess, App slots=73, ItemRow slots=72 and so on
React Compiler had quietly skipped part 7's App.jsx (Taken by the author on a cloud Linux machine on 3 November 2026. The number to the right of each row is how many times that row's ItemRow ran, added for the photo; the bar above each window was also drawn in.)

Part 7's App.jsx got a CompileError and was built as it was, remembering nothing. The reason: conditional expressions (?? and friends) inside a try/catch aren't supported yet. That's the try/catch part 7's submitClaim uses to catch a 401. The build succeeds and nothing warns you, so you'd never know without checking like this.

/** Send to Spring Boot; null if the sign-in expired (part 8: try/catch moved out of the component so React Compiler doesn't skip App) */
async function trySend(body, files) {
  try {
    return await sendClaim(body, files);
  } catch (e) {
    if (e instanceof SignedOut) return null;
    throw e;
  }
}

// inside App
  // Part 8: take the previous value (prev). No rows, so React Compiler can keep the function the same
  const change = (id, name, value) => setRows((prev) => prev.map((r) => (r.id === id ? { ...r, [name]: value } : r)));
  const add = () => {
    const row = newRow(); // make the new row outside (prev => … must give the same result if called twice)
    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];
    });
  };

Move the try/catch into trySend, a function outside the component, and App compiles too; as the right-hand column of section 1's table shows, the purpose and receipt components are no longer called. change, add and remove also took the prev => form. The new row is made outside and passed in, because whatever runs inside prev => … must give the same result if StrictMode calls it twice.

8

What's left is the browser

Time per keystroke at 1000 rows

JS (React and components) drawing (browser Layout and Paint) ① as is 98 ms ② memo alone 106 ms ③ memo + useCallback 55 ms ④ Compiler (code ①) 89 ms ⑤ Compiler (prev =>) 57 ms ⑥ Compiler + ③ 54 ms ⑦ ③ + visible rows only 5 ms memo and the Compiler shrink only the red (JS); the grey stays To cut the grey, draw fewer rows in the browser (⑦: only the 20 in view) Production build, per keystroke from a Chromium trace (median of 3)

memo and the Compiler alike only shrank the red (JS); the grey drawing stayed at about 50 ms. The trace shows Layout at 22 ms and Paint at 20 ms. Change the text in one box of a 1000-row table and the browser recomputes the whole table's layout and repaints it (table-layout: fixed or a fixed-height box barely helped).

The only cure is to hand the browser fewer rows. ⑦ draws only the 20 rows in view inside a 600px-high box, padding above and below with empty rows of the right height (the idea behind "virtual scrolling" in list screens). Per keystroke: 98 → 5 ms.

# Just the total (calcTotal), timed 2000 times in Node.js, per call
  10 rows     9.2 µs
  100 rows   50.0 µs
  1000 rows  453.6 µs
# 0.45 ms even at 1000 rows: next to 100 ms per keystroke, useMemo buys nothing

While we were at it, we checked whether the total deserves useMemo. Even at 1000 rows it takes 0.45 ms. Reach for useMemo once you've measured a calculation and found it heavy.

9

For this app

  • No hand-written memo or useCallback — line items top out at 10, and at 10 rows every approach is about 2 ms (section 5's second table)
  • Write code the compiler won't skip — try/catch outside components, state updates with prev =>. Neither hurts, and readability is unchanged
  • Don't switch the Compiler on for good yet — builds take twice as long with no difference at three rows. Keep the COMPILER=1 switch and decide again in part 15, when the release setup is settled
  • For screens with many rows (a claims list, say), first draw fewer rows — paginate, or draw only what's visible
10

On your own PC

In React Developer Tools' Profiler tab, press record, type a few characters into row 1's amount and stop: each component's time appears as a bar. Turn on "Record why each component rendered while profiling." under the gear and you also get reasons like "Props changed: (onChange)". Try it on section 4's memo-only version and the guilty function is named for you.

To measure production, use PROFILE=1 npm run build and npm run preview; to try the Compiler, add COMPILER=1. npm run rc-log shows how the compiler treated each component.

§ — On one page, and next time

  1. Measure in production before fixing. The claim form's three rows took about 2 ms: nothing to fix. Dev is about 6.5× slower.
  2. memo alone saves nothing when every render passes new functions: the props always differ.
  3. Keep functions the same with useCallback and prev =>: JS 47 → 3 ms at 1000 rows.
  4. React Compiler needs just the prev form: 47 → 7 ms. But it quietly skipped an App containing try/catch.
  5. A faster React leaves the browser's drawing (about 50 ms). To cut it, draw fewer rows.

AraiGlad I didn't memo everything… By the way, I'd like the signed-in name in the header too.

FuruiThat means passing props down several levels from App. Next time: where state lives. Context and useReducer, and the approval status changes on screen too.

References

Recorded by the author on a cloud Linux machine on 3 November 2026. An experiment page and babel-plugin-react-compiler 1.0.0 (with @rolldown/plugin-babel 0.2.4 and @babel/core 8.1.0) were added to part 7's project (Vite 8.3.5, React 19.3.1). Production builds were served with npm run preview and opened in Chromium 153.0.8010.12 driven by Playwright, sending input events one character at a time. Times per keystroke come from Chromium traces (devtools.timeline), median of three runs. The claim form measurements were connected to the back-end series' Spring Boot app.

Old-School Front-End, Frameworks part 8 — The cost of re-rendering / 2026.11

← Back to all posts