Large tables in React get slow for several different reasons. The useful part is knowing where the work happens, because each optimization fixes a different stage.
State changes
↓
React renders components
↓
Reconciliation compares old and new trees
↓
React updates the DOM
↓
Browser performs layout and paintWhat “rerender” means
function UserTable({ users }) {
return (
<div>
{users.map((user) => (
<UserRow key={user.id} user={user} />
))}
</div>
)
}If state inside UserTable changes, React runs UserTable again. So users.map(...) runs again, and React builds a new description of the UI.
That does not mean the DOM is recreated. React first compares the new result with the previous one.
Reconciliation
Reconciliation is React comparing the previous tree with the new one.
Old: A B C
New: A B D
A → unchanged
B → unchanged
C → removed
D → addedOnly the required DOM changes get committed. So there are three distinct steps:
| Step | What happens |
|---|---|
| Render | build the new tree |
| Reconciliation | compare old and new |
| Commit | update the real DOM |
A component can rerender and produce no DOM change at all. Rendering is cheap-ish; DOM mutation is not.
What key actually does
The common misunderstanding:
keyprevents the row from rerendering.
It does not. key tells React the identity of a list item.
Before: A B C
After: X A B CWith stable keys React understands that A, B and C are still the same items, rather than concluding that every row changed.
key = identityIt matters whenever items are added, removed, reordered, sorted or filtered.
key={user.id} // good
key={index} // usually bad — breaks on reorder, insert or filter
key={Math.random()} // very bad — every item looks brand new every renderkey={index} is defensible only for a list that never reorders and never has items inserted in the middle.
What does stop a rerender: React.memo
const UserRow = React.memo(function UserRow({ user }) {
return <div>{user.name}</div>
})Now React can skip rendering a row whose props haven’t changed.
Table rerenders
↓
1000 UserRow components encountered
↓
React.memo compares props
↓
unchanged rows are skippedThe distinction worth keeping:
key = which row is this?
React.memo = does this row need to render again?Object references matter
React.memo compares props shallowly, so identity is what counts.
const a = { id: 1, name: "Izzy" }
const b = { id: 1, name: "Izzy" }
a === b // falseThis defeats memoization completely — every row gets a new object:
const users = oldUsers.map((user) => ({ ...user }))This doesn’t — only the changed row gets a new reference:
setUsers((old) => old.map((user) => (user.id === changedId ? { ...user, name: newName } : user)))The untouched rows keep their previous references. That’s structural sharing, and it’s the thing that makes React.memo actually pay off.
Callbacks do the same thing
A new function every render:
<UserRow user={user} onClick={() => selectUser(user.id)} />means oldOnClick !== newOnClick, so React.memo stops helping.
const selectUser = useCallback((id: number) => {
setSelectedId(id);
}, []);Don’t reach for useCallback everywhere by reflex, though. The principle is just:
memoization only works with stable props
One caveat before you hand-memoize
The React Compiler went stable in October 2025. It inserts the equivalent of useMemo, useCallback and React.memo at build time by analysing data flow, so in a project that has it enabled most of this section is done for you.
Check whether your build has it on before hand-optimizing. If it does, treat a useMemo you write yourself as a statement of intent — something unusual is happening here — rather than a default reflex.
It does not remove the need for virtualization. Auto-memoization fixes how much React renders; it does nothing about how much DOM exists.
Why very large tables are still slow
10,000 rows × 20 columns = 200,000 cellsEven with perfect rerender behaviour, the browser still has to carry that DOM: memory, style recalculation, layout, paint, event handling.
So there are two separate problems:
| Problem | Fixed by |
|---|---|
| React renders too much | React.memo |
| The browser has too much DOM | virtualization |
React.memo does nothing for the second one.
Virtualization
Only render the rows currently on screen.
10,000 rows × ~40px → virtual height 400,000pxThe scrollbar behaves as if every row existed, but only the visible window is mounted:
Data: 10,000 rows
Virtual size: 10,000 rows
Actual DOM: ~30–60 rowsScrolling unmounts rows that left and mounts the ones that arrived. Also called windowing.
TanStack Virtual
Headless: it computes which items are visible, where they go, and the total size. You decide how to render them.
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => containerRef.current,
estimateSize: () => 40,
})
return (
<div ref={containerRef} style={{ height: 400, overflow: "auto" }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{rows[virtualRow.index].name}
</div>
))}
</div>
</div>
)Those four positioning properties are not optional decoration. Every item is taken out of flow, so top: 0; left: 0 pins them all to the same origin and transform is what separates them. Set only the transform and the layout works by accident, then breaks the moment anything else in the container changes.
TanStack Virtual
→ tells you WHAT to render
→ you decide HOW to render itThat’s what makes it work for tables, lists, grids, cards and custom layouts alike. For variable row heights, attach virtualizer.measureElement as a ref and give each item a data-index.
react-window
Same idea, component-oriented API.
import { List } from "react-window"
function RowComponent({ index, style, rows }) {
return <div style={style}>{rows[index].name}</div>
}
;<List
rowComponent={RowComponent}
rowCount={rows.length}
rowHeight={40}
rowProps={{ rows }}
style={{ height: 400 }}
/>v1 vs v2. react-window 2.x replaced
FixedSizeList/VariableSizeListand the children-as-function pattern with a singleListtakingrowComponentandrowProps. Plenty of tutorials still show the v1 API — check which major version the project is on before copying anything.
The trade:
TanStack Virtual = headless, more control
react-window = ready-made List/Grid, simpler for common casesBoth rest on the same idea: only render what is visible.
TanStack Table is a different thing
TanStack Table is not the virtualization layer. It manages table logic: rows, columns, sorting, filtering, selection, pagination, grouping, column visibility.
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
})table.getRowModel().rows may still hold 10,000 rows, and this still renders all of them:
table.getRowModel().rows.map(...)So the usual architecture stacks them:
TanStack Table → determines logical rows
TanStack Virtual → determines visible rows
React → renders only thoseKeep
dataandcolumnsin stable references —useMemo, module scope, or state. A fresh array literal on every render makes the table rebuild its row model constantly, which is a much bigger cost than anythingReact.memowas going to save you.
Pagination vs virtualization
Different problems entirely.
| Reduces | Example | |
|---|---|---|
| Pagination | data you load | 2,000,000 rows in the DB → 100 per request |
| Virtualization | DOM you create | 1000 rows loaded → ~30 mounted |
Large systems use both:
database
↓ server-side filter / sort / paginate
API
↓
TanStack Table
↓
TanStack Virtual
↓
small DOMThe four concepts
| Tool | Prevents |
|---|---|
key | lost component identity |
React.memo | unnecessary component renders |
| virtualization | unnecessary DOM elements |
| pagination | unnecessary data loading |
Four different problems. Reaching for the wrong one is why “I added React.memo everywhere and it’s still slow” is such a common story.
Worked example
An employee table: 50,000 employees, 25 columns.
50,000 × 25 = 1,250,000 cellsA better architecture:
SQL filter + sort
↓
backend returns 100 rows
↓
TanStack Table manages table state
↓
TanStack Virtual renders ~30 visible rows
↓
browser handles a small DOMThis matters far more than trying to memoize a million cells.
The best DOM node is the one you never create.
What to ask, in order
When a big table is slow, work down this list rather than guessing:
- How much data am I fetching?
- How many rows and cells are actually mounted?
- What state change causes the parent to rerender?
- Which children rerender as a result?
- Are object and callback references stable?
- Do I need virtualization?
- Should filtering, sorting and pagination move to the server?
Measure before you change anything — React DevTools Profiler, and “Highlight updates when components render” to see which parts actually re-render. Most guesses about which layer is slow are wrong.
Once these layers are separated, table performance stops being mysterious.