Back to News & Insights
Web Development September 21, 2026 · 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...

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 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.

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.

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.

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.

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.

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.

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.

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.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation