ADR-0018 Tables
Company standard tables — TanStack Table v9, headless, rendered with shadcn/ui table markup; lists that grow are paged, sorted, and filtered on the server through the URL and Query.
- Decision
- TanStack Table v9 + shadcn/ui TableBuild the app
- Status
- Accepted
- Date
- Scope
- Company-wide default for all new apps
Connections
Context
Section titled “Context”Every product shows lists of records: admin screens, reports, audit trails, search results. The surrounding decisions are made:
- Components: shadcn/ui on Base UI with Tailwind v4 (ADR-0011). Its
Tablecomponent is plain markup (table,thead,tr,th,td) with no state of its own, and its data table guide is written for TanStack Table - Data: TanStack Query behind Router loaders, and shareable UI state in URL search params validated by Zod (ADR-0012). Reads are
GETserver functions, which the rate limiter does not cover (ADR-0010) - Database: Postgres through Drizzle (ADR-0008), so sorting, filtering, and paging can run in SQL
- SSR: TanStack Start (ADR-0002) — a table should arrive in the first HTML, not after hydration
What is open: who owns table state (sort, filter, page, selection, column visibility), where rows are processed (browser or Postgres), and what renders the markup.
Forces:
- Real table markup.
<table>, header cells, andaria-sort— screen readers navigate tables by row and column - State worth sharing lives in the URL. A sorted, filtered page 3 must survive a reload and be pasteable
- No second design system. Tables use our tokens and components, no runtime CSS-in-JS (ADR-0011)
- Data grows. A table that works with 200 rows in month one has 200,000 in year two, and the fix must not be a rewrite. Very large sets also need virtualization (ADR-0019)
- Static tables stay trivial. A five-row read-only table should not need a library
Options considered
Section titled “Options considered”Table library
Section titled “Table library”1. TanStack Table v9 (headless)
Section titled “1. TanStack Table v9 (headless)”Table logic and state — sorting, filtering, pagination, selection, column visibility, pinning, grouping, expanding — as a hook; we render the markup. @tanstack/react-table 9.3.1, stable since 2026-08-04.
Pros
- Headless: the markup is ours, so shadcn/ui’s
Tableand our Tailwind tokens apply unchanged and the page is a real<table>. shadcn/ui’s data table guide is already written for v9 - Same maintainers as Router, Query, Start, and Form; MIT; the most used React table core by far (13.9 M npm downloads in the week to 2026-10-18, against 1.9 M for
ag-grid-communityand 1.6 M for@mui/x-data-grid) - Server-side processing is a first-class pattern:
manualPagination,manualSorting, andmanualFilteringmean “this data is already processed”, androwCountcomes from the server. The table never fetches; its guide names TanStack Query as the companion - v9 is lighter at scale. The maintainers’ benchmarks show up to 86% less retained heap on a million rows (about 2.71 GB to 380 MB, paginated, eight columns), and on average 37% less time to sort and 34% less to filter at 20,000+ rows
- Tree-shakeable: features are opt-in through
tableFeatures(), and a table with no optional features starts around 5 kB of bundled JS - Works correctly under the React Compiler; v8’s
useReactTablewas a documented incompatibility - Pairs with TanStack Virtual for large sets, with a guide and examples in its docs (ADR-0019)
Cons
- v9 is 11 weeks old (stable on 2026-08-04, after more than two years of work; 9.3.1 on 2026-10-12). Most blog posts, answers, and AI-generated snippets still show v8 (
useReactTable,getCoreRowModel), which does not compile against v9 - Headless means we build the toolbar, column header, pagination, and filters; shadcn/ui gives a guide and blocks, not a component
- Stable v9 depends on
@tanstack/react-store^0.11.2, which is pre-1.0 (ADR-0012 lists TanStack Store as pre-1.0) - No server row model, no grouping or export UI, no cell-editing UI: the server contract is ours
- The full package grew (14 kB to 25 kB, per the v9 announcement); only tables that register few features come out smaller than v8
2. AG Grid (Community / Enterprise)
Section titled “2. AG Grid (Community / Enterprise)”A complete grid widget that owns its DOM, virtualization, and theming.
Pros
- Batteries included: virtualization, column resizing and pinning, keyboard grid navigation; Community is MIT (
ag-grid-community36.2.0)
Cons
- Server-side row model, row grouping, pivoting, Excel export, integrated charts, and master/detail are Enterprise: a commercial licence listed from US$999 per developer, perpetual with one year of updates
- Its own DOM and theming system (Theme Builder, theme parameters) instead of shadcn/ui markup and our tokens — a second design system
3. MUI X Data Grid
Section titled “3. MUI X Data Grid”Pros: capable, mature (@mui/x-data-grid 9.16.0); the Community package is MIT.
Cons: needs Material UI and Emotion as peer dependencies (@mui/material, @emotion/react, @emotion/styled), against ADR-0011’s no-runtime-CSS-in-JS rule. The MIT grid is limited to pages of up to 100 rows; column pinning, row virtualization, and larger pages need the commercial Pro plan, and row grouping and Excel export need Premium.
4. Material React Table / Mantine React Table
Section titled “4. Material React Table / Mantine React Table”Pros: TanStack Table under a ready-made UI; MIT.
Cons: tied to the Material UI or Mantine design system, and pinned to TanStack Table v8 (material-react-table 3.2.1 depends on @tanstack/react-table 8.20.6, last published 2025-03-01; mantine-react-table’s last stable release, 1.3.4, is from 2025-02-17, and its v2 is still beta). Neither follows v9 yet.
5. Hand-rolled tables (shadcn/ui Table + .map())
Section titled “5. Hand-rolled tables (shadcn/ui Table + .map())”Pros: zero dependencies; exactly right for static tables. Cons: sorting, filtering, pagination, selection, and visibility state rebuilt in every product, each slightly different.
Where rows are processed
Section titled “Where rows are processed”A. Server-side by default, client-side for bounded sets
Section titled “A. Server-side by default, client-side for bounded sets”Postgres sorts, filters, and pages; the browser gets one page plus rowCount.
Pros
- Scales with the data, not with browser memory or response size; permissions and query rules stay on the server
- Fits Query and the URL: the search params are the query key
- TanStack’s own guide: when the server owns pagination, it should own filtering and sorting too
Cons
- Every interaction is a request, and every sortable or filterable column needs a SQL path and an index
- Page-number pagination needs a unique order, or pages repeat and skip rows; a large
OFFSETis computed on the server and gets slow (PostgreSQL docs). Keyset pagination fixes both but loses page numbers and “last page”
B. Client-side for everything
Section titled “B. Client-side for everything”Pros: instant interactions, no server code; TanStack’s guide calls a few thousand rows practical and stress-tests far more. Cons: ships the whole dataset — bytes, memory, and rows the user may not be allowed to see; goes stale; breaks as data grows. Client-side sorting over a server page sorts only that page.
Decision
Section titled “Decision”TanStack Table v9, headless, rendered with shadcn/ui table markup, is the company standard for interactive tables. Lists that can outgrow one response are paged, sorted, and filtered on the server, with their state in the URL and their rows in Query. Bounded lists may process rows in the browser. Static tables use shadcn/ui’s Table alone.
Concretely:
- Package:
@tanstack/react-table9.x. It re-exports@tanstack/table-core— never install the core separately. Give each table akey, and register it with@tanstack/react-table-devtoolsin development only - Write v9 only:
useTable,tableFeatures,table.FlexRender. v8 snippets (useReactTable,getCoreRowModel,getSortedRowModel) do not compile, and theuseLegacyTablebridge is deprecated and loads every feature — no new code uses it - One features module per table family (
data-table-features.ts, as in shadcn/ui’s guide):tableFeatures({ … })registers only the features, row models, andfilterFns/sortFnsthe tables use, and exportstypeof featuresfor theColumnDef,Row, andTablegenerics.stockFeaturesonly in prototypes - Hooks own data and state; components render. A feature hook (
useProjectsTable(search)) reads Query and the URL and returns thetable.DataTableis presentational: it renders shadcn/uiTable*from atableinstance and never fetches or navigates. Toolbar, column header, and pagination components take the narrow values and callbacks they use (pageIndex,pageCount,onPageChange), not the whole table. Pass values to cells, not Table objects (isSelected,onToggle, notrow):rowandcolumnare stable references, so under the React Compiler a child that reads state through them can render stale - Columns are module constants built with
createColumnHelper, andgetRowId: (row) => row.idis always set — selection and expansion must survive page changes, and row indexes don’t - Real table markup, owned once: shadcn/ui
Table,TableHeader,TableRow,TableHead,TableCellinsrc/components/ui/table.tsx(ADR-0011); every table has aTableCaption(visually hidden is fine) or anaria-label. A sort control is aButtoninside theth— neveronClickon theth— andaria-sort="ascending|descending"goes on the sorted header only. shadcn/ui’s guide does not setaria-sort, so one sharedDataTableColumnHeaderdoes, and screens can’t forget it. Sort icons arearia-hidden - The URL is the table state. A Zod 4 search schema in
validateSearch, with.catch()fallbacks:page,pageSize(from a short allowlist),sort(a column id from aconsttuple),dir,q, and filters. The server function validates the same schema (ADR-0005), andloaderDepslists exactly those keys - Query owns the rows, as in ADR-0012: an options factory
projectsTableOptions(search)with key["projects", "list", search], awaited in the loader, read withuseSuspenseQuery. TheGETserver function returns{ rows, rowCount }.datafromuseSuspenseQueryis defined and reference-stable, which the table needs — neverdata ?? []inline - Postgres does the work. The handler maps
sortthrough an allowlist onto Drizzle columns and appliesWHERE,ORDER BY,LIMIT, andOFFSET. EveryORDER BYends with a unique tiebreaker (id) — without a predictable order, different pages give inconsistent results. Never put client text into SQL. Index the columns we let users sort and filter on. Very large or append-heavy tables use keyset pagination (pageCount: -1, no “last page”) - Wire the table to the URL:
manualPagination,manualSorting, andmanualFilteringalltrue,rowCountfrom the server,statederived from the search params.onSortingChangeandonPaginationChangecallnavigate({ search: (prev) => … }), resolving updaters withfunctionalUpdate. Sort, filter, and page-size changes resetpageto 1 —manualPaginationturns the automatic reset off, so the handlers do it. Sort and page changes push history; the text filter keeps its draft inuseState, debounces it (ADR-0023), and navigates withreplace: true - Never mix the two modes: no client-side sort or filter over a server page. With
manualPagination, the other twomanual*flags are on as well - Client-side tables only for bounded sets that are safe to ship in full — a few thousand rows is practical, but measure with real columns on real devices — with the client row models registered (
createSortedRowModel(),createFilteredRowModel(),createPaginatedRowModel()). Never send rows the user may not see - Selection is local state keyed by
getRowId; it does not span pages unless we store the ids. Bulk actions arePOSTserver functions that re-check on the server (ADR-0013) - Paginate by default; virtualize only when needed: when users must scroll thousands of loaded rows, use TanStack Virtual over
table.getRowModel().rows(ADR-0019) - SSR: the table renders in the first HTML (checked on 9.3.1:
useTablewith a sorted row model, throughrenderToString).featuresandcolumnsare module constants because they hold no user data; rows, state, and theQueryClientare never module-level (ADR-0012) - Static tables are shadcn/ui
Tableplus.map(). TanStack Table comes in when a table needs sort, filter, paging, selection, or column visibility - Tests: sort clicks and page changes update the URL, covered by browser-mode component tests; axe runs on journeys that show tables (ADR-0014)
The deciding factors: markup we own, so shadcn/ui and our tokens apply with no second design system; state that fits the URL and Query we already chose; a documented server-side pattern that scales with the data; and one library, from the same team as our router, that also feeds virtualization. We accept a young major version, owning the toolbar and column-header components, and owning the server contract.
Consequences
Section titled “Consequences”Easier:
- One table API across products; sort, filter, and page state is linkable, survives reloads, and is typed by the Zod schemas we already have
- Tables render on the server and hydrate without a second fetch
- Accessible headers by default: the shared column header owns
aria-sort, focusable sort buttons, and hidden icons - Growth is a configuration change, not a rewrite: bounded tables go server-side by flipping the
manual*flags and adding the server function; huge ones add ADR-0019
Harder:
- v9 is young: community examples are mostly v8, so translate them rather than copy them. v9 methods live on shared prototypes, so
const { getValue } = rowno longer works — callrow.getValue() - The
data-tablecomponents (column header, toolbar, pagination, features module) are ours to build and maintain; they are the first candidate for the shared shadcn/ui registry (ADR-0011) - Every server-side table needs a column allowlist, an index per sortable column, a unique tiebreaker, and a
countquery; page-number pagination can drift when rows change between pages aria-sortand table semantics are our job; axe catches broken structure, not a missing sort state- Under the React Compiler, passing
roworcolumnobjects to memoized children can show stale cells — pass values, or subscribe inside the child - The Table release train is separate from Query, Router, and Start; it also pulls in pre-1.0 TanStack Store
Given up: AG Grid’s batteries (server-side row model, pivoting, Excel export, integrated charts), MUI X’s ready-made grid, and the shortcut of a packaged table UI. Accepted — owned markup and one design system matter more for a company default; a product that needs a spreadsheet-style grid with pivoting and Excel export can write an exception ADR for AG Grid Enterprise.

