-
Decide server-side vs client-side FIRST — it changes the whole architecture. The test is "does the full dataset fit in memory and stay responsive to filter/sort in the browser?"
| Client-side | Server-side |
|---|
| Row count | ≲ 10k (hard ceiling ~50k) | 10k → millions |
| Sort/filter/paginate | in JS, instant | the API does it; table is a controlled view |
| Source of truth | the loaded array | the server query (sort/filter/page in the request) |
| TanStack flag | getSortedRowModel, getFilteredRowModel, getPaginationRowModel | manualSorting/manualFiltering/manualPagination: true + pageCount/rowCount |
In server-side mode, debounce filter input (~300ms), send sort, filter, and the cursor (from design-api-pagination — keyset, not OFFSET) to the API, and keep table state controlled (state={{ sorting, columnFilters, pagination }} + onSortingChange etc.). Never load 200k rows to filter client-side "because it's simpler" — it OOMs the tab.
-
Virtualize rows whenever you render more than ~100 at once — this is non-negotiable for big grids. Mounting 10k <tr> nodes blows the DOM budget and kills scroll. Use TanStack Virtual (@tanstack/react-virtual, framework-agnostic, the default) or react-window (lighter, fixed/variable list). Only the visible window + overscan mounts.
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 36,
overscan: 8,
measureElement: el => el.getBoundingClientRect().height,
});
Rules: give the scroll container a fixed height and overflow:auto; set contain: strict (or layout paint) on it; use transform: translateY() for row offset, not top. Do not use a native <table> with table-layout:auto over thousands of rows — the browser re-measures every column on each row; switch to display:grid/explicit <col> widths or table-layout:fixed. For dynamic row heights, measureElement + data-index; expect a one-frame jump unless you pre-measure.
-
Build the logic headless with TanStack Table v8; pick AG Grid only when you need its enterprise features turned-key. TanStack Table is headless — it computes models, you render every DOM node (full control, ~14kb, pairs with Virtual). Define columns with createColumnHelper<Row>() for type-safe accessors:
const col = createColumnHelper<Person>();
const columns = [
col.accessor('name', { header: 'Name', enableSorting: true }),
col.accessor('amount', { cell: c => fmtMoney(c.getValue()), enableColumnFilter: true }),
];
const table = useReactTable({ data, columns, getRowId: r => r.id,
getCoreRowModel: getCoreRowModel() });
Choose AG Grid instead when you need row grouping/tree data, pivoting, built-in column pinning + Excel export, or a million-row server-row-model without hand-rolling it — it ships those, but it's heavier and styling is its own system. Don't reach for a styled mega-component (<DataGrid> Material/MUI X) if you need fine control; you'll fight it.
-
Column resize / reorder / pin — wire the table's column features and persist the layout. TanStack: enableColumnResizing: true + columnResizeMode:'onChange' (live) vs 'onEnd' (commit on mouseup, cheaper); read width via header.getSize() and apply with CSS vars so resizing doesn't re-render every cell. Reorder = drag the header and reorder columnOrder state (use the table's setColumnOrder; a dnd-kit sortable context for the drag). Pin with column.getIsPinned() + position: sticky; left: <accumulated width>; z-index and a shadow on the last pinned column. Persist {columnOrder, columnSizing, columnPinning, columnVisibility} to localStorage (or the user profile) keyed by table id and rehydrate as initial state.
-
Inline edit = optimistic update + rollback, never block on the network. On commit (Enter / blur), write the new value into the cached rows immediately, fire the mutation, and roll back on error. With TanStack Query:
useMutation({ mutationFn: patchCell,
onMutate: async (next) => {
await qc.cancelQueries({ queryKey });
const prev = qc.getQueryData(queryKey);
qc.setQueryData(queryKey, patch(prev, next));
return { prev };
},
onError: (_e, _v, ctx) => qc.setQueryData(queryKey, ctx.prev),
onSettled: () => qc.invalidateQueries({ queryKey }),
});
Edit mode: single cell, aria-readonly off, focus the input, Esc cancels, Enter commits + moves down, Tab moves right. Validate per-cell before the optimistic write (type/range); show the error inline and keep the old value. This is the per-cell case — multi-field <form> rules belong to build-form-validation.
-
Row selection: use a stable getRowId and decide select-all semantics. TanStack enableRowSelection, state rowSelection: Record<rowId, boolean> — keyed by your row id, not the index, so selection survives sort/filter/page. The header checkbox has three states (none/some/all) via table.getIsSomePageRowsSelected() → indeterminate. Critical decision: "select all" = current page or entire matching dataset? In server-side mode you can't select rows you haven't loaded — implement "select all N matching" as a predicate (the active filter) sent to the bulk endpoint, not a list of ids, and show "All 4,213 selected" with a clear-selection affordance.
-
Keyboard nav + ARIA grid — roving tabindex over a real grid role, virtualization-aware. A data grid is one tab stop: the container/active cell has tabindex=0, every other cell tabindex=-1; arrow keys move the active cell and move the 0. Roles: container role="grid", rows role="row", cells role="gridcell", header cells role="columnheader" with aria-sort="ascending|descending|none". Because virtualization removes off-screen rows from the DOM, you must set aria-rowcount={totalRows} on the grid and aria-rowindex (1-based, header = 1) on every rendered row, and aria-colcount/aria-colindex for horizontally virtualized columns — otherwise SRs announce "row 12 of 30" instead of "of 50000". Key map:
| Key | Action |
|---|
| ↑ ↓ ← → | move active cell |
| Home / End | first / last cell in row |
| Ctrl+Home / Ctrl+End | first / last cell in grid |
| PageUp / PageDown | scroll a viewport of rows (and move focus) |
| Enter / F2 | enter edit mode; Esc exits |
| Space | toggle row selection |
When focus moves to a virtualized row that's scrolled out, call rowVirtualizer.scrollToIndex(i) before focusing so the node exists. Sort headers must be operable with Enter/Space and update aria-sort. Deep WCAG conformance → audit-accessibility-wcag.
-
Sticky headers (and sticky pinned columns) with position: sticky. Header row: position: sticky; top: 0; z-index: 2 inside the scroll container (sticky is scoped to the nearest scrolling ancestor — the header must live inside the same overflow:auto element as the rows, not above it). Pinned column cells: sticky; left: 0; z-index: 1; the top-left corner (sticky header + pinned col) needs the higher z-index. Give sticky cells an opaque background (transparent sticky cells show rows bleeding through) and a bottom/right box-shadow so the freeze line reads.
-
CSV export of the current view, off the main thread for large sets. Export reflects the active sort/filter/column-visibility, not the raw data. Build CSV correctly: quote fields containing , " \n, double internal quotes ("a ""b"" c"), prefix the file with (UTF-8 BOM) so Excel reads UTF-8, and defend against CSV injection — prefix any cell starting with = + - @ \t \r with a ' (formula-injection in spreadsheets). For server-side / huge datasets, hit a streaming export endpoint (the server pages with the keyset cursor and streams rows) or generate in a Web Worker + Blob so a 100k-row export doesn't freeze the UI; trigger download via an object URL.
-
Every grid has four states — design them, don't default to a blank box. Loading → skeleton rows matching column widths (not a centered spinner; preserves layout, no shift). Empty (no data exists yet) → illustration + primary action ("Add your first record"). No results (filters exclude everything) → "No matches" + a Clear filters button (distinct from empty — the fix differs). Error → message + Retry that refires the query, keeping prior data visible if you have it. In server-side infinite scroll, show a row-level loading sentinel at the bottom and an error row with retry, not a full-table swap.
Done = the grid renders the target scale without jank (virtualized, bounded DOM), sort/filter/paginate run server-side for large datasets against the keyset API, columns resize/reorder/pin and persist, inline edit is optimistic with rollback, selection and edit are id-stable, the grid is one keyboard tab stop with a correct ARIA grid (rowcount/rowindex aware of virtualization), headers and pinned columns stick opaquely, CSV export of the current view is correctly quoted + injection-safe, and all four data states are designed — all proven by checks 1–10.