Dev.to WebDev 🛠 Dev 👁 0 📖 6 min read

I Benchmarked Five Ways to Speed Up a Slow React Table. Two of Them Did Nothing.

A table of 4,000 rows with a filter box above it. You type "acme" and the cursor lags behind your fingers. Every frontend developer hits this shape eventually, and the advice for fixing it is remarkably consistent: wrap

A table of 4,000 rows with a filter box above it. You type "acme" and the cursor lags behind your fingers.

Every frontend developer hits this shape eventually, and the advice for fixing it is remarkably consistent: wrap the row in React.memo. That advice is where I started too, and it turns out to be close to worthless on its own.

So rather than argue about it, I built a harness that measures five different fixes on the same data and reports what each one is actually worth. Every variant's code and the timing function are in this post, so you can rebuild it and check me.

Here's what it found.

The results first

Production build, 4,000 rows, median of two passes, measuring the slowest keystroke:

Variant Longest re-render Does it still filter?
1. Naive 166 ms yes
2. memo() only 153 ms yes
3. memo + useCallback + useMemo 47 ms yes
4. children bailout 0.4 ms no — see below
5. Virtualized 3.6 ms yes

Measured on a 2-core Linux VM against a production build. Your absolute numbers will differ — mine did by 10–15% between runs. The ratios are the point, and those were stable across six runs.

Two things jump out, and the second one is a trap.

memo on its own bought 13ms out of 166. Within noise of doing nothing.

Variant 4 looks like the winner and isn't. It's 0.4ms because it stops doing the work, not because it does the work faster. More on that below, because it's the most interesting result here.

Why memo alone does nothing

Here's variant 2:

const InvoiceRow = memo(function InvoiceRow({ invoice, onSelect }) {
  return (
    <tr onClick={() => onSelect(invoice.id)}>
      <td>{invoice.customer}</td>
      <td>{formatMoney(invoice.amount)}</td>
    </tr>
  );
});

Correct on its own terms. But look at what the parent hands it:

<InvoiceRow
  invoice={inv}
  onSelect={(id) => openDrawer(id)}   // ← new function object, every render
/>

memo does a shallow comparison of props. That arrow function is freshly created on every render of the parent, so prevProps.onSelect === nextProps.onSelect is false, memo concludes the props changed, and it re-renders. Every time. For every row.

The same thing happens with inline objects (style={{ padding: 8 }}) and inline arrays (columns={[a, b]}). And with the filtered array itself — invoices.filter(...) computed during render is a new array reference every time, so a memoized table can never bail out either.

memo on a component whose parent passes inline callbacks is decoration. You pay for a comparison on every render and buy nothing. The 13ms it appeared to save is measurement noise; in one of my six runs it came out slower than the naive version.

Variant 3 fixes it properly:

const visible = useMemo(
  () => invoices.filter((inv) =>
    inv.customer.toLowerCase().includes(query.toLowerCase())
  ),
  [invoices, query]
);

const handleSelect = useCallback((id) => setSelected(id), []);

166ms → 47ms. A 3.5x improvement, and the first thing on this list that actually works.

Two details that are easy to get wrong. handleSelect takes the id as an argument rather than closing over it — write useCallback(() => setSelected(inv.id), [inv.id]) inside the .map() and you've created a fresh function per row per render, which puts you straight back at variant 2. And the empty dependency array is honest rather than a trick to quiet the linter: React guarantees setSelected is stable.

Worth saying plainly: that useMemo around the filter is there to stabilise a reference, not to skip a slow computation. Filtering 4,000 small objects takes well under a millisecond — memoizing it costs more than doing it. If you can't say which of those two reasons applies to a useMemo you're writing, delete it.

Variant 4: the fastest result, and why you can't have it

This is the one I'd flag to anyone quoting benchmarks like these.

A component's children prop is constructed by whoever writes the JSX, not by the component that renders it. So if children is created in a parent that isn't re-rendering, that element object keeps the same reference, React compares it, sees it's identical, and skips the entire subtree. No memo anywhere.

function InvoicesPage() {
  const [invoices] = useState(() => loadInvoices());
  return (
    <SearchShell>
      <InvoiceTable invoices={invoices} />   {/* created here */}
    </SearchShell>
  );
}

function SearchShell({ children }) {
  const [query, setQuery] = useState('');    // changes here
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      {children}
    </>
  );
}

0.4ms, because the table genuinely does not re-render at all.

But the table can no longer see query. That's not a detail I glossed over in the benchmark — it's inherent to the pattern. The value that changes lives in SearchShell, and children was built outside it. So variant 4 isn't a faster filter. It's no filter.

I left it in the benchmark anyway, because the pattern is genuinely valuable for the large category of state that doesn't need to reach the expensive subtree: a drawer being open, a hovered row, a collapsed sidebar, an unsaved form draft. For all of those, this deletes the problem instead of managing it, and it can't be silently broken later the way a memo chain can.

For filtering, you need variant 3 or variant 5.

Variant 5: stop rendering rows nobody can see

Everything above reduces how often you render 4,000 rows. This reduces the 4,000.

The viewport fits about 30 rows. The other 3,970 are DOM nodes that exist so the scrollbar is the right height. Virtualization renders the visible window plus a small buffer and translates it as you scroll.

import { useVirtualizer } from '@tanstack/react-virtual';

const virtualizer = useVirtualizer({
  count: visible.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 41,
  overscan: 8,
});

166ms → 3.6ms, and it still filters. In the harness, 23 rows exist in the DOM instead of 4,000.

The costs are real and rarely mentioned. Ctrl+F stops finding off-screen content. Keyboard navigation and screen readers need deliberate work. Variable-height rows need measurement rather than estimation. Your print and CSV-export paths need a separate non-virtualized render, and you will forget this and hear about it from a user. Virtualizing real <table> rows also needs CSS grid or table-layout: fixed — my harness uses divs for exactly this reason.

Below about a thousand rows it's usually not worth the complexity. Above a few thousand it's the only thing that really works.

How the measurement works

No DevTools, because React's <Profiler> is compiled out of production builds and I wanted production numbers. input is a discrete event, so React flushes the re-render synchronously inside dispatchEvent — which means wall time around the dispatch is the render plus commit cost:

function timedKeystroke(el, value) {
  const setter = Object.getOwnPropertyDescriptor(
    window.HTMLInputElement.prototype, 'value'
  ).set;
  const t0 = performance.now();
  setter.call(el, value);
  el.dispatchEvent(new Event('input', { bubbles: true }));
  return performance.now() - t0;
}

It types a, ac, acm, acme, keeps the slowest of the four, and takes the median of two passes. StrictMode is off — it double-renders and would inflate everything.

What I'd actually do, in order

  1. Measure before changing anything. React DevTools → Components → "Highlight updates when components render", then type one character. If the whole table flashes, you're done diagnosing in four seconds. Write the number down; "it feels laggy" isn't something you can improve against.
  2. Move state down to the smallest component that needs it. Free, and nothing can silently undo it.
  3. Pass expensive subtrees as children where the value doesn't need to reach them. Bailouts with no memo to break.
  4. Then memo — with every object, array and function prop stabilised, or genuinely don't bother.
  5. Virtualize once the list is long enough to earn the accessibility tax.
  6. Measure again, and revert anything that didn't move the number. Memoization you can't justify isn't an optimization, it's maintenance debt with a good reputation.

Most advice starts at step four, because that's the step with the famous API. Steps two and three have no API at all, which is why they're the ones that hold.

Reproducing it

Everything needed is above: the five variants, and the timedKeystroke function that measures them. Drop them into a Vite React app over 4,000 generated rows, switch variants behind tabs, and you have the harness. Build for production and turn StrictMode off before you believe any number.

Numbers on your hardware will differ from mine. The one I'd most like corroborated is the gap between variant 1 and variant 2 — on my setup it was noise across six runs, and if it isn't on yours I want to know why.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.