Skip to content

Build1 publisher3 min readPublished

Two stable references took a 4,000-row table's slowest keystroke from 166ms to 47ms

A dev.to harness timed five fixes for a laggy filtered React table on a 2-core VM. React.memo on its own moved the slowest keystroke by 13ms, and the 0.4ms variant gets there by removing the filter.

The Engineer · Build desk

Illustration accompanying Two stable references took a 4,000-row table's slowest keystroke from 166ms to 47ms

What happened

  • A dev.to harness timed five fixes for a laggy 4,000-row invoice table with a filter box, on a production build on a 2-core Linux VM, reporting the median of two passes for the slowest keystroke.
  • Wrapping the row component in React.memo moved that slowest keystroke by 13ms out of 166, which the author describes as within noise of doing nothing.
  • Stabilising the filtered array with useMemo and the click handler with useCallback took the same keystroke from 166ms to 47ms, the first change on the list the author says actually works.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint memo can only skip work when every prop is reference-stable, so one inline arrow in the parent's JSX turns the comparison into a per-row cost with nothing bought back.
  • cost A team that adopts memo alone on these numbers is buying a saving smaller than the harness's run-to-run spread, and paying a shallow comparison on every row of every render for it.
  • contradiction Ranking 0.4ms against 47ms puts a table that filters beside one that cannot, so the fastest row in the results is not available to anyone who needs the filter box to work.
  • decision The winning variant's useMemo stabilises a reference and the filter itself runs in under a millisecond, so every other useMemo in the file now has to answer which of those two jobs it is doing.

React.memo wraps a component in a shallow props comparison. The parent re-renders, React builds fresh props for every row, and memo walks the keys comparing them with `===` before it decides whether to skip. An inline callback in the parent's JSX defeats that: `onSelect={(id) => openDrawer(id)}` allocates a new function object on every parent render, so `prevProps.onSelect === nextProps.onSelect` is false, memo concludes the props changed, and the row re-renders [6]. Inline objects such as `style={{ padding: 8 }}` and inline arrays such as `columns={[a, b]}` fail the same way, and so does the filtered list, because `invoices.filter(...)` computed during render is a new array reference every time [7].

That puts the 13ms inside the harness's own variation. Absolute numbers moved 10 to 15 percent between runs, according to the post, while the ratios held across six [3]. Ten percent of a 166ms baseline is 16.6ms [19]. In one run of six, the memoized version came out slower than the naive one [5].

The fix that worked has two failure modes of its own. The handler takes the id as an argument instead of closing over it; write `useCallback(() => setSelected(inv.id), [inv.id])` inside the `.map()` and you are back to allocating a fresh function per row per render [9]. The empty dependency array holds because React guarantees `setSelected` is stable [10].

The `useMemo` around the filter is there for reference identity. Filtering 4,000 small objects takes well under a millisecond, and memoizing it costs more than running it [11]. "If you can't say which of those two reasons applies to a useMemo you're writing, delete it," the author wrote [12].

The 0.4ms variant runs on element identity instead. A component's `children` prop is constructed by whoever writes the JSX, not by the component that renders it [13]. `InvoicesPage` creates `<InvoiceTable invoices={invoices} />` and passes it into `SearchShell`, which holds the query state and the input [14]. When `setQuery` re-renders `SearchShell`, the child element object it received still has the same reference, React compares it, sees it is identical, and skips the entire subtree without a memo anywhere [13]. The value that changes lives in `SearchShell` and the children element was built outside it, so the table filters nothing [16].

For the 3.5x to transfer, the same shape has to dominate your keystroke: a parent re-render that rebuilds every row, rows cheap enough that the count is what hurts, props otherwise stable. The published setup is 4,000 rows of two cells each, production build, slowest keystroke, median of two passes, on a 2-core Linux VM [2][22]. A median of two passes is the mean of two numbers [21]. A shallow comparison against a freshly allocated function fails on any hardware, so the direction of the memo result should hold where the milliseconds do not [6].

The advice under test is to wrap the row in React.memo, which the post calls close to worthless on its own [17]. On this evidence that verdict covers memo applied to a parent that passes inline props, measured on one machine, with every variant's code and the timing function published so the numbers can be rechecked [1].

What to watch

  • Whether anyone reruns the published harness with more than two passes per variant, or on hardware with more than two cores.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories