# React Tables

You are working in a React 19 project with TypeScript, TanStack Table v9 (`@tanstack/react-table`) and shadcn/ui-style Table parts (`Table`, `TableHeader`, `TableRow`, `TableHead`, `TableCell`…). Write code for the versions in `package.json`: v9 changed how a table is built, how state is read and several names, and most examples you remember are v8.

## Before you write code

- Check the major version of `@tanstack/react-table`. These rules are for v9; on a v8 project follow v8's docs and never mix the two APIs.
- v9 ships its own guides: read `node_modules/@tanstack/react-table/skills/` (getting-started, table-state, migrate-v8-to-v9) and the feature you are adding in `node_modules/@tanstack/table-core/skills/table-features/references/`. Exact signatures: `node_modules/@tanstack/table-core/dist/features/<feature>/*.types.d.ts`.
- Reuse the project's Table parts (`components/ui/table.tsx` or similar) and the shape of its existing data tables.

## Build the table

A v9 table has only the core row model until you register features. Register each feature, then its row-model slot and the named functions its columns use, in one module-scope `tableFeatures` call:

```tsx
"use client";

import {
  columnFilteringFeature, createColumnHelper, createFilteredRowModel,
  createPaginatedRowModel, createSortedRowModel, filterFn_includesString,
  rowPaginationFeature, rowSortingFeature, sortFn_alphanumeric,
  tableFeatures, useTable,
} from "@tanstack/react-table";

type Payment = { id: string; email: string; amount: number };

const features = tableFeatures({
  columnFilteringFeature,
  rowSortingFeature,
  rowPaginationFeature,
  filteredRowModel: createFilteredRowModel(),
  sortedRowModel: createSortedRowModel(),
  paginatedRowModel: createPaginatedRowModel(),
  filterFns: { includesString: filterFn_includesString },
  sortFns: { alphanumeric: sortFn_alphanumeric },
});

const helper = createColumnHelper<typeof features, Payment>();
const columns = helper.columns([
  helper.accessor("email", { header: "Email", filterFn: "includesString", sortFn: "alphanumeric" }),
  helper.accessor("amount", { header: "Amount" }),
]);

export function PaymentsTable({ data }: { data: Payment[] }) {
  const table = useTable({ features, columns, data, getRowId: (row) => row.id });
  // render table.getHeaderGroups() and table.getRowModel().rows
}
```

- Keep `features` and `columns` at module scope (`useMemo` them if they depend on props). Keep `data` stable: state, a query result or a prop, never `rows.filter(…)` or `data ?? []` inline; hoist `const EMPTY: Payment[] = []`. A new array each render reruns every row model.
- Type columns through `createColumnHelper<typeof features, TData>()` and wrap the list in `helper.columns([...])` so each column keeps its value type. Never annotate the array as `ColumnDef<…>[]`.
- `helper.accessor("key")` for data (deep keys work: `"customer.name"`), `helper.accessor((row) => …, { id: "domain" })` for derived values, `helper.display({ id: "select" })` for checkbox and action columns, `helper.group` for grouped headers.
- Accessors return primitives (string, number, Date); sorting and filtering run on them. Formatting and JSX go in `cell`, with formatters (`Intl.NumberFormat`) hoisted out of render.
- Call row, cell, column and header methods on their object: `row.getValue("email")`, `rows.map((row) => row.getVisibleCells())`. v9 put them on prototypes, so `const { getValue } = row` or a bare `row.getVisibleCells` loses `this`. Destructuring the render context (`cell: ({ getValue, row }) => …`) is fine.
- Render with `<table.FlexRender header={header} />` and `<table.FlexRender cell={cell} />`, skipping `header.isPlaceholder`.
- Type column meta with a `columnMeta: metaHelper<{ align?: "start" | "end" }>()` slot, not global `declare module` merging.
- When several tables share features and cell components, define them once with `createTableHook` at module scope (`useAppTable`, `createAppColumnHelper`); one-off tables use `useTable`.

## State

- Let the table own state by default; seed it with `initialState` (`{ pagination: { pageIndex: 0, pageSize: 20 } }`).
- To control a slice, pass both the value and its callback: `state: { sorting }` with `onSortingChange: setSorting`. A React setter accepts the updater as is; anywhere else apply `functionalUpdate(updater, previous)`.
- Read state in render from `table.state.sorting`. `table.atoms.<slice>.get()` and `table.store.state` are snapshots that never re-render.
- `useTable` re-renders on every state change. For large tables pass a selector as its second argument, `(state) => ({ pagination: state.pagination })`, and subscribe lower with `<table.Subscribe selector={…}>`. Under the React Compiler, a child given only `row` that reads `row.getIsSelected()` won't update: subscribe inside it or pass the value as a prop.

## Sorting, filtering and pagination

- Sorting: a `<button>` inside the header cell when `header.column.getCanSort()`, wired to `getToggleSortingHandler()`, showing `getIsSorted()` (`"asc" | "desc" | false`). Custom comparators return ascending order; the table reverses them. Use `sortUndefined: "last"` and `enableSortingRemoval: false` when the product needs them.
- Column filters: `column.setFilterValue(value)` and `column.getFilterValue()`; an empty value removes the filter.
- Global search: add `globalFilteringFeature` after `columnFilteringFeature`, set `globalFilterFn: "includesString"`, call `table.setGlobalFilter(value)`, and exclude columns with `getColumnCanGlobalFilter`.
- Faceted filters (status with counts): add `columnFacetingFeature` with `facetedRowModel: createFacetedRowModel()` and `facetedUniqueValues: createFacetedUniqueValues()` (`facetedMinMaxValues` for numeric ranges). `column.getFacetedUniqueValues()` is a `Map` of value to count. A multi-select over a single-value column uses `filterFn: "arrHas"` (register `filterFn_arrHas`) with an array filter value; numeric ranges use `inNumberRange`.
- Client pagination: `rowPaginationFeature` with `paginatedRowModel`; controls use `getCanPreviousPage()`, `getCanNextPage()`, `setPageIndex`, `setPageSize` and `getPageCount()`.

## Server-side data and the URL

- When the dataset is too large to send whole or the server owns the query, set `manualPagination`, `manualSorting` and `manualFiltering` together, pass `rowCount` (the server total), and pass exactly the processed page as `data`. Manual flags never fetch anything; they only skip client processing. Never sort or filter one loaded page on the client while the server paginates.
- Make the URL the single owner of page, page size, sort and filters: parse the search params with a schema (clamp the page, allow only sortable column ids), pass them as controlled `state`, and in each `on…Change` apply `functionalUpdate(updater, current)` and write the result back with a replace navigation. Reset `pageIndex` to 0 when sorting or filters change: manual pagination does not do it for you. Debounce text filters that trigger requests.
- With server rendering, query on the server from the search params and pass `rows`, `rowCount` and the parsed state to the client table; navigate inside `startTransition` for a pending state. With a client data library, put every server-owned slice in the query key and keep the previous page shown while the next loads.
- Never copy fetched rows into state with an effect: pass the query result straight to `data` with a stable empty fallback. Facet counts computed on the client only cover the loaded page; fetch them from the server in manual mode.

## Visibility, selection and bulk actions

- Column visibility needs `columnVisibilityFeature`. Render `row.getVisibleCells()` (`getAllCells()` includes hidden ones) and give full-width rows `colSpan={table.getVisibleLeafColumns().length}`. Build the menu from `table.getAllLeafColumns().filter((column) => column.getCanHide())`. Only an explicit `false` hides a column; `enableHiding: false` only forbids hiding it.
- Row selection needs `rowSelectionFeature` and `getRowId: (row) => row.id`: index ids break across pages, sorting and refetches.
- Header checkbox: checked from `table.getIsAllPageRowsSelected()`, indeterminate when `table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()` (v9's "some" includes "all"), toggled with `table.toggleAllPageRowsSelected(checked)`. Base UI's Checkbox takes `indeterminate`, Radix's `checked="indeterminate"`.
- Row checkbox: `row.getIsSelected()`, disabled when `!row.getCanSelect()`. `row.getToggleSelectedHandler()` given the click event adds Shift-click ranges.
- Bulk actions use `table.getSelectedRowIds()`, which survives server pages (`getSelectedRowModel()` holds only loaded rows). Show the count, confirm destructive actions, disable while pending, then `table.resetRowSelection(true)`.

## Long tables, loading and empty states

- Paginate by default. When one view must render more than a few hundred rows, virtualize with TanStack Virtual: count `table.getRowModel().rows` (never raw `data`), key by `row.id`, scroll a fixed-height container, measure with `measureElement` and `data-index`, and position rows (`display: grid` on `tbody` and rows with `translateY`, or spacer rows). Keep the real `<table>` with a sticky header, `aria-rowcount` on the table and `aria-rowindex` on rows, and widths from `column.getSize()` (`columnSizingFeature`).
- First load: skeleton rows matching the columns and row height. Refetch: keep the rows, dim them and set `aria-busy="true"`; never blank the table between pages.
- Empty: one row spanning the visible columns that tells "nothing yet" from "no matches" and offers to clear filters. Errors offer a retry.

## Accessibility

- Use real table elements through the Table parts, never a grid of divs, and name the table (`aria-label` or a caption).
- Header cells get `scope="col"`. Only the sorted column carries `aria-sort` (`"ascending"` or `"descending"`).
- Label every checkbox ("Select all rows on this page", `` `Select ${row.original.email}` ``) and every icon-only button.
- Don't make whole rows clickable without a keyboard path: put a link or button in the primary cell.
- Make a sideways-scrolling container focusable and named; announce result counts after filtering in a polite live region.

## Do not

- Do not write v8 APIs: `useReactTable`, `getCoreRowModel: getCoreRowModel()` and the other `get*RowModel()` options, `sortingFn`/`sortingFns`, `table.getState()`, `onStateChange`, `VisibilityState` (now `ColumnVisibilityState`), `columnSizingInfo` (now `columnResizing`), pinning `left`/`right` (now `start`/`end`), table-level `enablePinning`.
- Do not use `useLegacyTable` from `@tanstack/react-table/legacy` or `stockFeatures` in new code: migration bridges that bundle every feature.
- Do not cast to reach a missing method: register its feature.
- Do not number rows or build ids from `row.index`; use `getRowId` and `row.getDisplayIndex()`.

## Before you finish

- Run the type checker; add no `any` or casts to silence feature types.
- Sort each sortable column both ways, filter to zero rows, page to the end and back, toggle columns, select across pages and run a bulk action.
- In server mode, check that the URL holds the state and that reload and the back button restore it.
- Tab through sort buttons, checkboxes and row actions; check names and `aria-sort` with a screen reader. Try 1,000 rows and the loading, empty and error states.
