How I build production React tables with TanStack Table, virtualization, server-side pagination, inline editing, and memoization that actually holds up.
Thursday afternoon, a creator opened the dashboard on the live-video creator platform I led engineering at, hit the filter input on her audience table, and watched her browser tab go white for almost four seconds. Six hundred rows. Six hundred. Not six thousand. Her laptop wasn’t underpowered either. The table was just doing what the original author had told it to do, which was rebuild the whole DOM on every keystroke.
I’d seen the same thing on a creator-side dashboard at the creator economy platform I worked at, that one with millions of customers. Different stack, same shape. Some engineer somewhere had typed data.map((row) => <Row ... />) inside a component that re-renders on every parent state change, and the table held up fine in dev with 50 rows of seed data, and then production happened.
OK so here’s where I’ve landed after building probably a dozen of these things at different scales. For any React table that has to handle more than a couple hundred rows, supports sorting, supports inline editing, and is allowed to talk to a real backend, the answer is TanStack Table plus TanStack Virtual plus server-side state. Not AG Grid. Not Material UI’s DataGrid. Not a hand-rolled useState + map. I’ll show you what that actually looks like.
The default pattern, the one that every tutorial teaches you, is a sin in production. You have an array in state, you map it to rows, and the parent owns the sort order, the filter text, the selection set, all of it. Every keystroke in the filter input re-renders the parent, which re-creates every row prop, which blows past React.memo because the prop reference changed.
Bundle that with a styled-components or Emotion table where each cell carries its own dynamic styles and you’ve got a render cost that goes up linearly with row count. At three hundred rows you feel it. At a thousand you can’t type.
I’m not proud of this but I shipped exactly that table once. Five PRs went out together on a Friday afternoon, our visual-regression CI didn’t exist yet, and a global CSS reset that came with the new design system bundle quietly tanked spacing on every section element on the page. The audience table happened to be one of them. Two hours of broken creator profiles on a high-traffic Friday. We rolled back the bundle, scoped the reset to a data-attribute boundary, and put Chromatic snapshots on the top 50 routes the following week. The lesson on the table side was narrower. A data-heavy surface that re-renders eagerly will surface every CSS sin in your codebase the moment a user actually types something.
TanStack Table is headless. It gives you a state machine for column visibility, sorting, filtering, pagination, row selection, expansion, and grouping. It doesn’t ship a single line of markup. You render whatever you want.
That last part matters. Every commercial grid I’ve worked with eventually traps you. You want a custom popover in a cell, you fight the grid’s render pipeline. You want server-driven sort with a quirky tie-breaker, you patch around the grid’s internal state. With TanStack Table you keep your own JSX. The library just tells you what state changed.
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table'
import { useMemo } from 'react'
type Audience = {
id: string
email: string
joinedAt: string
status: 'active' | 'churned' | 'paused'
lifetimeValueCents: number
}
const columns: ColumnDef<Audience>[] = [
{
accessorKey: 'email',
header: 'Email',
enableSorting: true,
meta: { editable: true },
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ getValue }) => <StatusPill value={getValue<Audience['status']>()} />,
},
{
accessorKey: 'lifetimeValueCents',
header: 'LTV',
cell: ({ getValue }) =>
formatUSD(getValue<number>() / 100),
enableSorting: true,
},
{
accessorKey: 'joinedAt',
header: 'Joined',
cell: ({ getValue }) => formatDate(getValue<string>()),
enableSorting: true,
},
]
export function useAudienceTable(rows: Audience[]) {
// columns must be referentially stable across renders or memo on Row will not hold
const stableColumns = useMemo(() => columns, [])
return useReactTable({
data: rows,
columns: stableColumns,
getCoreRowModel: getCoreRowModel(),
manualSorting: true,
manualPagination: true,
})
}
Two things to call out. The meta slot on a column is where I stash anything render-time, like an editable flag or a width hint. And the columns array lives outside the component, or inside a useMemo with an empty deps array. If the column reference changes per render, every memoized row will still re-render. This trips people up constantly.
Past about two hundred rows, you cannot afford to mount every row in the DOM. The browser will paint them, the accessibility tree will index them, and your scroll will jank.
TanStack Virtual is a small companion library to TanStack Table. You give it a scroll container ref, the total row count, and an estimated row height. It tells you which rows are visible plus a few above and below for overscan.
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
export function AudienceTable({ rows }: { rows: Audience[] }) {
const table = useAudienceTable(rows)
const scrollRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: table.getRowModel().rows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 44,
overscan: 8,
})
return (
<div ref={scrollRef} className="h-[600px] overflow-auto">
<table className="w-full">
<thead className="sticky top-0 bg-white z-10">{/* headers ... */}</thead>
<tbody style={{ height: virtualizer.getTotalSize() }} className="relative">
{virtualizer.getVirtualItems().map((vItem) => {
const row = table.getRowModel().rows[vItem.index]
return (
<tr
key={row.id}
data-index={vItem.index}
ref={virtualizer.measureElement}
className="absolute left-0 right-0"
style={{ transform: `translateY(${vItem.start}px)` }}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
)
}
The ref={virtualizer.measureElement} is the bit most people skip. If your rows can vary in height because of multi-line text or a status pill that wraps, measure them. Otherwise your scroll math drifts and you get phantom whitespace at the bottom.
If you’re paginating client-side, you don’t need this article. You probably don’t have a scaling problem yet.
The moment your dataset doesn’t fit in memory, the client becomes a renderer. Sort, filter, and page state belong in the URL. The URL goes to the backend. The backend returns a page. The client renders it. That’s it.
I’m picky about this because I once shipped a setup where filter state lived in React state, query state lived in TanStack Query, and the URL was decorative. A teammate refactored the table to a worker-cached path at the edge, and the cache key dropped the filter segment because no one realized it was load-bearing. Users started seeing other users’ filtered data on shared links. We rolled back the worker version, redeployed with locale and filter explicitly in the key, and added a deploy-time check that diffs cache-key composition. Lesson, in one sentence, cache keys are part of your public API. Same principle for table state. The URL is the API.
import { useQuery, keepPreviousData } from '@tanstack/react-query'
type SortDir = 'asc' | 'desc'
type QueryArgs = {
cursor: string | null
sortBy: keyof Audience
sortDir: SortDir
filter: string
}
export function useAudiencePage(args: QueryArgs) {
return useQuery({
queryKey: ['audience', args],
queryFn: async ({ signal }) => {
const res = await fetch(
`/api/audience?cursor=${args.cursor ?? ''}` +
`&sort=${args.sortBy}:${args.sortDir}` +
`&q=${encodeURIComponent(args.filter)}`,
{ signal },
)
if (!res.ok) throw new Error(`audience fetch ${res.status}`)
return (await res.json()) as { items: Audience[]; nextCursor: string | null }
},
placeholderData: keepPreviousData,
staleTime: 15_000,
})
}
keepPreviousData is doing the work of preventing the table from blanking out between page loads. The signal cancels stale requests when the user keeps typing in the filter. Race conditions in tables are usually here, not in the rendering.
Inline editing is where most tables fall apart. The naive approach lifts the edited value into table-level state, which re-renders every row on every keystroke. Don’t do that.
Push the edit state down into the cell. The row, the table, the parent, none of them care. They only care when you commit.
import { useMutation } from '@tanstack/react-query'
import { useState, useEffect, memo } from 'react'
type Props = {
rowId: string
field: keyof Audience
initial: string
}
export const EditableCell = memo(function EditableCell({ rowId, field, initial }: Props) {
const [value, setValue] = useState(initial)
const [error, setError] = useState<string | null>(null)
useEffect(() => setValue(initial), [initial])
const mutation = useMutation({
mutationKey: ['audience.update', rowId, field],
mutationFn: async (next: string) => {
const res = await fetch(`/api/audience/${rowId}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ [field]: next }),
})
if (!res.ok) throw new Error(await res.text())
},
onError: (err) => {
setError(err instanceof Error ? err.message : 'update failed')
setValue(initial)
},
onSuccess: () => setError(null),
})
return (
<div className="flex flex-col gap-1">
<input
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={() => value !== initial && mutation.mutate(value)}
className="bg-transparent outline-none"
/>
{error ? <span className="text-xs text-red-600">{error}</span> : null}
</div>
)
})
memo on the cell only works because the props (rowId, field, initial) are primitives. The moment you pass a callback into a memoized cell, you’d better be useCallback-ing it at the level where it’s defined or that memo is decorative.
Row selection is the other place teams accidentally re-render the world. TanStack Table will store selection internally if you let it, but if you also need that selection elsewhere on the page (a bulk action bar, a side panel summary), lift it carefully.
I keep selection in a tiny Zustand store outside React state. The table reads from it via a selector. The bulk action bar reads from it via a different selector. Neither re-renders the other’s tree.
Export is similar. Building a CSV in memory with a .reduce over a million rows is how you crash a tab. Stream it.
import { create } from 'zustand'
type SelectionState = {
ids: Set<string>
toggle: (id: string) => void
clear: () => void
}
export const useSelection = create<SelectionState>((set) => ({
ids: new Set(),
toggle: (id) =>
set((s) => {
const next = new Set(s.ids)
next.has(id) ? next.delete(id) : next.add(id)
return { ids: next }
}),
clear: () => set({ ids: new Set() }),
}))
export async function streamCsvExport(ids: string[]) {
const res = await fetch('/api/audience/export', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ids }),
})
if (!res.body) throw new Error('no body')
const reader = res.body.getReader()
const chunks: BlobPart[] = []
for (;;) {
const { value, done } = await reader.read()
if (done) break
chunks.push(value)
}
const blob = new Blob(chunks, { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `audience-${Date.now()}.csv`
a.click()
URL.revokeObjectURL(url)
}
The backend streams the CSV row by row out of a cursor. The client just glues chunks. For exports past tens of thousands of rows, switch the client to writing chunks directly to a file via the File System Access API where supported, and fall back to Blob on the rest. Either way, do not build the whole string in memory.
A few things I check on every PR that touches a table.
Columns are defined outside the component or inside a useMemo([]). If a teammate inlines a new column array per render, every row re-renders. Catch it in review.
Row callbacks (onEdit, onDelete, onSelect) are useCallback-stabilized at the parent, or pulled from a store directly inside the cell. Passing an inline arrow function down to a memoized row defeats the point.
Cell components are wrapped in React.memo only after their props are primitive or referentially stable. Memo on a cell that takes a fresh object every render costs more than it saves.
And measure. React DevTools profiler, ten seconds of filter typing, look at the flame graph. If your header is re-rendering on every keystroke, your sort state is in the wrong place.
Thanks for reading. If you’ve got thoughts, send them my way.