| name | build-data-table |
| description | Builds production data grids that stay fast and accessible at 10k–1M+ rows — decide server-side vs client-side sort/filter/paginate by the dataset-fits-in-memory test (client only under ~10k rows, otherwise push to the API and treat the table as a controlled view of server state), ROW-VIRTUALIZE with TanStack Virtual or react-window so only the visible window mounts (fixed estimateSize, overscan 5–10, measureElement for dynamic rows, contain:strict, and a real scroll container — never table-layout:auto over thousands of rows), build the headless logic with TanStack Table v8 (or AG Grid when you need pinning/grouping/enterprise out of the box), add column resize/reorder/pin, inline edit with optimistic update + rollback on error, row selection with a stable rowId, full keyboard nav with roving tabindex over an ARIA grid (role=grid/row/gridcell, aria-sort, aria-rowcount/aria-rowindex so virtualization stays announced), position:sticky headers, streaming CSV export that doesn't block the main thread, and explicit empty/loading-skeleton/error/no-results states. |
| when_to_use | Building a sortable/filterable/paginated table, an editable grid, or any list that must render thousands+ of rows without jank — virtualization, column pin/resize/reorder, inline edit, keyboard grid nav, or CSV export. Distinct from build-react-component (scaffolds one component's props/server-vs-client boundary; this is the full grid subsystem) and design-api-pagination (defines the backend cursor/keyset paging contract; this consumes it for server-side mode) — and from optimize-react-rerenders (fixes wasted renders in general React; this owns the table-specific row-memoization + virtualization). |
When to Use
Reach for this skill when you're building a real data grid, not a static <table>:
- "Make this table sortable/filterable and paginated" — and decide server vs client
- "The table janks / freezes scrolling 50k rows" → you need virtualization
- "Let users resize, reorder, and pin columns; persist the layout"
- "Inline-edit a cell and save it optimistically with rollback on failure"
- "Add row selection (checkboxes, select-all-across-pages) and bulk actions"
- "Make the grid keyboard-navigable and screen-reader accessible (ARIA grid)"
- "Export the current (filtered/sorted) view to CSV"
NOT this skill:
- Scaffolding a single component's props contract / Server-vs-Client boundary, not a grid subsystem → build-react-component
- The backend list endpoint's cursor/keyset contract, page_size caps,
{data,next_cursor,has_more} envelope → design-api-pagination (this skill consumes that contract in server-side mode)
- Generic "why is React re-rendering" wasted-render diagnosis outside the table → optimize-react-rerenders (this owns only the row/cell memoization the grid needs)
- Wiring TanStack Query caching/mutations/optimistic infra in general → manage-client-server-state (this skill calls into it for the data layer)
- A spreadsheet with formulas/multi-sheet/cell-range math → build-spreadsheet (a grid is read-mostly tabular UI, not a calc engine)
- Field-level form rules across a
<form> (not per-cell inline edit) → build-form-validation
- Deep WCAG audit of the finished UI → audit-accessibility-wcag (this skill builds the grid a11y baseline it then verifies)
- Charts/heatmaps from the data → write-data-viz; cleaning/reshaping the rows before display → wrangle-tabular-data
- Live-updating rows over a socket → build-realtime-channel feeds this grid; merge into rows keyed by stable id
Steps
-
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,
: el.().,
});
Common Errors
- Rendering all rows, then "optimizing" later. 10k
<tr> is already janky; virtualize from the start (step 2). Bolting it on after layout/CSS assumes a normal <table> is the biggest rewrite.
- Client-side sort/filter on a server-scale dataset. Loading 100k+ rows to filter in JS OOMs the tab and waterfalls. Use
manualSorting/Filtering/Pagination + the paged API (step 1).
<table> with table-layout:auto over thousands of rows. The browser re-measures every column per row → quadratic. Use table-layout:fixed / display:grid with explicit widths (step 2).
- Selection/edit keyed by row index. Sort or filter and the wrong rows are selected/edited. Key by a stable
getRowId (steps 5–6).
- No
aria-rowcount/aria-rowindex with virtualization. SR announces the rendered window ("12 of 30"), not the real total. Set them from the full count (step 7).
- Every cell in the tab order. Tabbing through 50 columns × visible rows is unusable. One tab stop + roving tabindex + arrow keys (step 7).
- Sticky header outside the scroll container.
position:sticky only sticks within its scrolling ancestor — a header above the overflow:auto div won't stick. Put it inside (step 8).
- Transparent sticky/pinned cells. Rows show through the frozen header/column. Opaque background + shadow (step 8).
- Inline edit that awaits the server before updating UI. Feels broken on slow networks. Optimistic write + rollback on error (step 5).
- CSV without quoting / injection guard. Commas/newlines corrupt columns; a cell starting
=cmd|... executes in Excel. Quote + escape + prefix dangerous leading chars + BOM (step 9).
- Exporting raw data instead of the current view. Users expect the filtered/sorted/visible columns they see. Export from the table's current row model (step 9).
- One "no data" state for both empty and filtered-out. Users can't tell "nothing exists" from "filters hide everything." Split them; give no-results a Clear-filters button (step 10).
- Re-creating
columns/data inline each render. New array identity busts memoization and re-runs every row model. Define columns module-level or , keep referentially stable (defer deep render perf to optimize-react-rerenders).
Verify
- Scale: load the target row count (10k / 100k) and scroll fast top-to-bottom — DOM node count stays bounded (only window + overscan in the inspector), no dropped frames.
- Server mode: sort/filter/page issue new API requests with the right params (keyset cursor, not OFFSET); the table never holds the full dataset; filter input is debounced.
- Columns: resize, reorder, pin, hide — layout holds, pinned columns freeze with a shadow, and the layout persists across reload.
- Inline edit: commit shows the new value instantly; force the mutation to fail → it rolls back to the old value and surfaces an error; Esc cancels, Enter advances.
- Selection: select rows, then sort/filter/page → the same rows stay selected (id-keyed); header checkbox shows indeterminate; "select all matching" sends a predicate, not loaded ids.
- Keyboard: Tab reaches the grid once; arrows/Home/End/Ctrl+Home move the active cell; focusing a scrolled-out row scrolls it into view first; sort headers fire on Enter/Space.
- A11y: screen reader announces
role=grid, column headers with aria-sort, and the real total via aria-rowcount (not the virtualized window); run audit-accessibility-wcag for full conformance.
- Sticky: header stays pinned on vertical scroll, pinned columns on horizontal scroll, corner z-index correct, no bleed-through.
- Export: CSV of a filtered+sorted view opens in Excel with UTF-8 intact, fields with commas/quotes/newlines are correct, a
=-leading cell is neutralized, and a 100k-row export doesn't freeze the tab.
- States: empty, no-results (with Clear-filters), loading skeleton, and error (with Retry) each render distinctly and the error path recovers.
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.