Skip to content

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

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 Table component 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 GET server 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, and aria-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

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 Table and 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-community and 1.6 M for @mui/x-data-grid)
  • Server-side processing is a first-class pattern: manualPagination, manualSorting, and manualFiltering mean “this data is already processed”, and rowCount comes 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 useReactTable was 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

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-community 36.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

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.

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 OFFSET is computed on the server and gets slow (PostgreSQL docs). Keyset pagination fixes both but loses page numbers and “last page”

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.

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-table 9.x. It re-exports @tanstack/table-core — never install the core separately. Give each table a key, and register it with @tanstack/react-table-devtools in development only
  • Write v9 only: useTable, tableFeatures, table.FlexRender. v8 snippets (useReactTable, getCoreRowModel, getSortedRowModel) do not compile, and the useLegacyTable bridge 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, and filterFns / sortFns the tables use, and exports typeof features for the ColumnDef, Row, and Table generics. stockFeatures only in prototypes
  • Hooks own data and state; components render. A feature hook (useProjectsTable(search)) reads Query and the URL and returns the table. DataTable is presentational: it renders shadcn/ui Table* from a table instance 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, not row): row and column are stable references, so under the React Compiler a child that reads state through them can render stale
  • Columns are module constants built with createColumnHelper, and getRowId: (row) => row.id is 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, TableCell in src/components/ui/table.tsx (ADR-0011); every table has a TableCaption (visually hidden is fine) or an aria-label. A sort control is a Button inside the th — never onClick on the th — and aria-sort="ascending|descending" goes on the sorted header only. shadcn/ui’s guide does not set aria-sort, so one shared DataTableColumnHeader does, and screens can’t forget it. Sort icons are aria-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 a const tuple), dir, q, and filters. The server function validates the same schema (ADR-0005), and loaderDeps lists 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 with useSuspenseQuery. The GET server function returns { rows, rowCount }. data from useSuspenseQuery is defined and reference-stable, which the table needs — never data ?? [] inline
  • Postgres does the work. The handler maps sort through an allowlist onto Drizzle columns and applies WHERE, ORDER BY, LIMIT, and OFFSET. Every ORDER BY ends 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, and manualFiltering all true, rowCount from the server, state derived from the search params. onSortingChange and onPaginationChange call navigate({ search: (prev) => … }), resolving updaters with functionalUpdate. Sort, filter, and page-size changes reset page to 1 — manualPagination turns the automatic reset off, so the handlers do it. Sort and page changes push history; the text filter keeps its draft in useState, debounces it (ADR-0023), and navigates with replace: true
  • Never mix the two modes: no client-side sort or filter over a server page. With manualPagination, the other two manual* 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 are POST server 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: useTable with a sorted row model, through renderToString). features and columns are module constants because they hold no user data; rows, state, and the QueryClient are never module-level (ADR-0012)
  • Static tables are shadcn/ui Table plus .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.

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 } = row no longer works — call row.getValue()
  • The data-table components (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 count query; page-number pagination can drift when rows change between pages
  • aria-sort and table semantics are our job; axe catches broken structure, not a missing sort state
  • Under the React Compiler, passing row or column objects 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.