# AdaptTable — full documentation > AdaptTable v3 documentation: a framework-neutral data engine in @adapttable/core, headless React bindings in @adapttable/react, and native table adapters for Mantine, MUI, Chakra UI, Ant Design, Radix Themes, Base UI, shadcn/ui and unstyled Tailwind. Individually imported features include filtering, grouping, pivot tables, formulas, editing, virtualization and export. Responsive mobile cards, URL state, i18n/RTL and optional provider-neutral AI sessions. MIT licensed; applications own data and persistence. Start with getting-started and concepts for package ownership, then features for opt-in composition. Use the v2-to-v3 migration guide when upgrading; historical migration examples describe their named versions, not current import paths. Vue and Angular bindings are not shipped. This file is generated from the canonical guides; the linked index is at https://adapttable.orwamahmoud.com/llms.txt. --- # Get started with AdaptTable — React table for your UI kit ▶ **Nothing to install yet — [open the live demo](https://adapttable.orwamahmoud.com/react/demo/) and use it.** Flip between [Mantine](https://adapttable.orwamahmoud.com/react/demo/?kit=mantine) · [MUI](https://adapttable.orwamahmoud.com/react/demo/?kit=mui) · [Chakra](https://adapttable.orwamahmoud.com/react/demo/?kit=chakra) · [Ant Design](https://adapttable.orwamahmoud.com/react/demo/?kit=antd) · [Radix](https://adapttable.orwamahmoud.com/react/demo/?kit=radix) · [Base UI](https://adapttable.orwamahmoud.com/react/demo/?kit=base-ui) · [shadcn](https://adapttable.orwamahmoud.com/react/demo/?kit=shadcn) · [Tailwind](https://adapttable.orwamahmoud.com/react/demo/?kit=tailwind) on the same data, and toggle grouping and inline editing while you are there. AdaptTable is a headless, UI-agnostic React data table. Pick the adapter for your design system and you get a styled, sortable, searchable, paginated table with URL-synced state, RTL, and dark mode. Filters, selection with bulk actions, and every other feature are opt-in imports composed in `features={[...]}`. ## Install The fastest path is the CLI — it detects your UI kit from `package.json`, prints the install command, and scaffolds a starter `src/PeopleTable.tsx`: ```bash npx @adapttable/cli init ``` Prefer zero install first? Open a live starter in [StackBlitz (Mantine)](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine) — or [any other kit](#try-it-in-stackblitz). A plain adapter `DataTable` is 72–80 kB min+gzip (measured 2026-09-28 from packed fixtures; React and the kit stay external). The [FAQ](./faq.md#how-big-is-it--is-it-tree-shakeable) has the method and the rest of the grid. Or install manually: `@adapttable/core`, the adapter for your kit, and the kit's own packages (peer dependencies — skip what you already have). `react` / `react-dom` 18 or 19 are peers everywhere. ```bash # Mantine pnpm add @adapttable/core @adapttable/mantine @mantine/core @mantine/hooks # Material UI pnpm add @adapttable/core @adapttable/mui @mui/material # Chakra UI (v3) pnpm add @adapttable/core @adapttable/chakra @chakra-ui/react @emotion/react # Ant Design pnpm add @adapttable/core @adapttable/antd antd # Radix Themes pnpm add @adapttable/core @adapttable/radix @radix-ui/themes # Base UI pnpm add @adapttable/core @adapttable/base-ui @base-ui/react # shadcn/ui — one import, pre-wired with the shadcn class preset pnpm add @adapttable/core @adapttable/shadcn # Tailwind / unstyled — bring your own classes pnpm add @adapttable/core @adapttable/unstyled ``` Every kit depends on `@adapttable/react`, so it is always installed — but a package manager with a strict layout (pnpm by default) lets you import only what your own `package.json` lists. Importing a hook such as `useDensityUrlState` or `useHighlight` from `@adapttable/react` then needs it added too: ```bash pnpm add @adapttable/react ``` Keep it on the version your kit pins, which `pnpm why @adapttable/react` shows. ## Supported versions | Dependency | Supported range | | ----------------- | --------------------------------------------------------------------- | | React / React DOM | `^18.0.0 \|\| ^19.0.0` (CI-tested on 18.3 / 19.0 / 19.2) | | Mantine | `^7.2.0 \|\| ^8 \|\| ^9` | | MUI | `^6.1.2 \|\| ^7 \|\| ^8 \|\| ^9` | | Chakra UI | `^3.13.0` | | Ant Design | `^6` | | Radix Themes | `^3` | | Base UI | `^1.6.0` | | Node.js | `>=22.12.0` (packed releases are CI-tested on Node 22.12 and Node 24) | Each floor is the lowest version the adapter actually runs on — verified by automated install-and-render probes, not guesswork. ## Provider setup Each adapter renders with its UI kit's own components, so your app needs that kit's provider once at the root — exactly as the kit's docs describe. **Mantine** ```tsx // main.tsx — once per app, straight from Mantine's own setup guide. import "@mantine/core/styles.css"; import { MantineProvider } from "@mantine/core"; ; ``` **Material UI** — works with the default theme out of the box; wrap in `ThemeProvider` to customize: ```tsx import { createTheme, ThemeProvider } from "@mui/material"; ; ``` **Chakra UI** (v3) — the provider takes a system; use the built-in `defaultSystem` or your own: ```tsx import { ChakraProvider, defaultSystem } from "@chakra-ui/react"; ; ``` **Ant Design** — works without a provider; add `ConfigProvider` for theme or locale: ```tsx import { ConfigProvider } from "antd"; ; ``` **Radix Themes** — import the Themes stylesheet and wrap in `` (see Radix Themes docs). **Base UI** — no provider. Import `@adapttable/base-ui` (it side-effect-loads minimal chrome CSS) or `@adapttable/base-ui/styles.css` once at the app entry. **shadcn/ui** — no provider. `@adapttable/shadcn` is the unstyled adapter pre-wired with the shadcn class preset, so it inherits your app's existing shadcn/ui theme (its CSS variables + Tailwind config) automatically. **Unstyled** — no provider. It renders semantic HTML with `data-*` and `className` hooks for your own CSS or Tailwind. ## Your first table Pass `data` and declare columns — that's the whole thing: ```tsx // or import from "@adapttable/mui", "@adapttable/chakra", "@adapttable/antd", // "@adapttable/radix", "@adapttable/base-ui", "@adapttable/shadcn", // "@adapttable/unstyled" — same props everywhere. import { DataTable } from "@adapttable/mantine"; import { filters } from "@adapttable/mantine/filters"; interface Person { id: string; name: string; role: string; status: string; hiredAt: string; } const PEOPLE: Person[] = [ { id: "1", name: "Ada Lovelace", role: "Engineer", status: "active", hiredAt: "2021-03-01", }, { id: "2", name: "Alan Turing", role: "Founder", status: "active", hiredAt: "2019-06-15", }, { id: "3", name: "Grace Hopper", role: "Admiral", status: "retired", hiredAt: "2018-01-20", }, ]; export function PeopleTable() { return ( r.id} features={[filters([])]} /> ); } ``` Column `filter` declarations need the filters feature — `filters([])` when every filter lives on a column, or pass standalone defs to the factory (below). See [feature composition](./features.md). What you just got without writing any of it: search, sorting, pagination (paged on desktop, infinite scroll on mobile), URL-synced state (reload-safe, shareable links), empty/loading states, a mobile card layout, and a filter form built from those `filter` declarations with kit-native widgets — each filter also drives its own removable chip, URL parsing, and row predicate. - Headers auto-derive from keys (`hiredAt` → "Hired At"); pass `header` to control the text in any language. - Dot-path keys reach nested values: `{ key: "department.name" }`. - Filters that aren't columns go in a table-level array: ```tsx r.id} features={[ filters([ { key: "companyId", type: "select", label: "Company", options: companies, }, { key: "budget", type: "numberRange" }, ]), ]} /> ``` ## Try it in StackBlitz Prefer to try before installing? Each starter is a minimal Vite app — one table on a demo dataset — that boots in the browser with no local setup. Every starter composes the same features from its own kit: column filters with chips, a drag-to-group panel with subtotals, the column menu, resizing, multi-column sort, cell navigation, inline editing with undo and redo, and CSV export. Pick your kit: - [Mantine](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine) - [Material UI](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mui) - [Chakra UI](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/chakra) - [Ant Design](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/antd) - [Radix Themes](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/radix) - [Base UI](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/base-ui) - [shadcn/ui](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/shadcn) - [Unstyled / Tailwind](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/unstyled) The source for each lives in [`starters/`](https://github.com/orwa-mahmoud/adapttable/tree/main/starters). ## Where next - [Columns](./columns.md) — headers, custom cells, the Columns menu (show/hide, reorder, pin), resizing. - [Inline cell editing](./cell-editing.md) — compose `editing()`, kit-native editors, keyboard flow. - [Row reordering](./row-reordering.md) — opt-in `rowReorder()`, Space-lift keyboard, dataset-relative indices. - [Row pinning](./row-pinning.md) — sticky top and bottom rows, `{ top, bottom }` ids - [Pinned summary rows](./pinned-summary-rows.md) — host-owned totals outside the row model - [Row and column spanning](./row-spanning.md) — `getCellSpan`, one cell list per row - [Full-width and separator rows](./full-width-rows.md) — `extraRows`, host-injected slots - [Row styling and heights](./row-styling.md) — `rowStyle`, `rowHeight`, variable-height virtualizer - [Filtering](./filtering.md) — every filter type, options sources, chips, popover vs drawer. - [Data tiers](./data-tiers.md) — server data without a query library (`onQueryChange`), or full control via `source` and TanStack Query. - [Demo](https://adapttable.orwamahmoud.com/react/demo/) — every adapter, live. Full surface: [API reference](./api.md) · [core concepts](./concepts.md). --- # AdaptTable concepts — headless core, TableSource, adapters ▶ **See it working:** [the live demo](https://adapttable.orwamahmoud.com/react/demo/) — one dataset, one feature set, re-rendered by every real adapter. ## The `TableSource` contract Everything in AdaptTable revolves around one idea: a **`TableSource`** — a uniform contract that a table consumes regardless of where its rows came from. Every built-in source builder (`useFrontendData`, `useServerData`, `useQuerySource`) fulfils it, and the table renders without knowing which produced it. ```ts interface TableSource { // data rows: readonly TRow[]; total: number; isLoading: boolean; // FIRST load only — refreshes never re-raise it isFetching: boolean; // any in-flight request isFetchingNextPage: boolean; // an append fetch (infinite mode) hasNextPage: boolean; // more rows can be APPENDED (always false when paged) fetchNextPage: () => void; // appends; no-op in paged mode error: Error | null; paginationMode: "infinite" | "paged"; // state page: number; limit: number; defaultLimit: number; // page size when the URL names none search: string; sortBy: string | undefined; sortDir: "asc" | "desc" | undefined; sortLevels: readonly SortLevel[]; // multi-sort chain, empty when unused extra: ExtraFilters; // Record groupBy: string | undefined; // comma-separated grouping keys // setters setPage: (next: number) => void; setLimit: (next: number) => void; setSort: (key: string | undefined, dir?: "asc" | "desc") => void; toggleSortLevel: (key: string) => void; setSearch: (next: string) => void; setExtra: (key: string, value: FilterValue) => void; setExtras: (updates: ExtraFilters) => void; setGroupBy: (key: string | undefined) => void; clearExtras: () => void; clearAll: () => void; } ``` Optional members a source adds when it can: `refetch`, `allFilteredRows`, `allSearchedRows`, `facets`, `filterTree` / `setFilterTree`, `capabilities`, `groups`, `tableEngine`, `initializeGroupBy`, `setGroupAggregateOverrides`, and the aggregate fields (`groupAggregateOverrides`, `groupAggregations`, `queryAggregates`, `aggregateOperations`, `honorsAggregates`). The [API reference](./api.md) has each one's type. Because the table is agnostic to the source's origin, you can switch between in-memory and server data — or [build a custom source](./custom-table-source.md) — without touching the UI. ## Source builders ### `useFrontendData` In-memory. Filters by a searchable-text projector, sorts by a column's `sortValue` (or a custom `getSortValue`), and slices for the current page. ```ts const source = useFrontendData({ data, columns, getSearchText, getSortValue }); ``` ### `useQuerySource` Server-paginated. Wraps a caller-supplied `useInfiniteQuery` hook and maps each page to rows via `selectPage`. Flattens pages in infinite mode, returns the latest page in paged mode, and clamps out-of-range pages. ```ts const source = useQuerySource({ usePaginatedQuery, selectPage, baseParams }); ``` ## Columns ```ts interface ColumnDef { key: string; // unique; also the backend sortBy value header?: ReactNode; // pre-translated; derived from `key` when omitted Cell?: ComponentType<{ row: TRow; rowIndex: number }>; // stable identity accessor?: (row: TRow) => ReactNode; // lightweight sortValue?: (row: TRow) => string | number | boolean | null | undefined; sortable?: boolean; width?: number | string; align?: "start" | "center" | "end"; mobileLabel?: string; hideOnMobile?: boolean; hideOnDesktop?: boolean; } ``` ## Pagination modes `"auto"` (the default) resolves to **infinite scroll on mobile** and **paged on desktop**, by the same rule as the card layout: the 768px media query, or your `mobileBreakpoint`, and `forceMobile` over both. Force a mode with `paginationMode: "paged" | "infinite"`. In infinite mode the adapters auto-load the next page when the bottom of the list scrolls into view (via `IntersectionObserver`, prefetching ~200px early), and also render an explicit **Load more** button as a keyboard- and screen-reader-friendly fallback. The auto-load behaviour is packaged as a headless hook, `useInfiniteScroll`, exported from `@adapttable/react` — attach the returned ref to a sentinel element after your last row to get the same behaviour in custom markup (`rootMargin` sets how early it fires, default `"200px"`): ```tsx const sentinelRef = useInfiniteScroll({ hasNextPage: source.hasNextPage, isFetchingNextPage: source.isFetchingNextPage, fetchNextPage: source.fetchNextPage, itemCount: source.rows.length, // re-arms so short pages keep loading enabled: source.paginationMode === "infinite", }); // …render rows…
; ``` It is SSR- and test-safe: where `IntersectionObserver` is unavailable it no-ops, leaving the Load more button as the path forward. ## Optional virtualization Long infinite lists can opt into row/card windowing with the `virtualize()` feature. `@adapttable/react` exports the underlying `useTableVirtualization`, and the ready adapters wire it into their desktop rows and mobile cards. With no `maxHeight` the window tracks the page scroll; add `maxHeight` and the same feature virtualizes inside the scroll box instead — fifty thousand rows in a 380px panel stay a handful of DOM nodes. Ant Design maps the same `virtualize()` feature to antd's native virtual table mode. ```tsx import { DataTable } from "@adapttable/mantine"; import { virtualize } from "@adapttable/mantine/virtualize"; interface Person { id: string; name: string; } export function People({ people }: { people: Person[] }) { return ( row.id} paginationMode="infinite" features={[virtualize({ estimateRowSize: 56, estimateCardSize: 140 })]} /> ); } ``` Virtualization is optional. Leave it off for small lists or paged tables; turn it on for long infinite lists. ## Responsive cards Adapters automatically switch from table rows to mobile cards at `mobileBreakpoint` (default 768px); `forceMobile` pins the card layout. A card shows every column without `hideOnMobile`, so `hideOnMobile` on the low-value columns is what shapes it; `hideOnDesktop` adds a field to cards only. `renderCard` replaces a card's body while its shell — selection, toggles, row actions — stays. ```tsx const columns = [ { key: "name" }, { key: "team" }, { key: "email", hideOnMobile: true }, ]; row.id} />; ``` ## The engine, and why it has no React in it Filtering, sorting, paging and grouping are decisions about data, not about the DOM. They live in `@adapttable/core` as a plain object with no framework in its import graph, and `@adapttable/react` is the binding that subscribes a component tree to one. ```ts import { createTableEngine } from "@adapttable/core"; const engine = createTableEngine({ data: people, columns: [{ key: "name", sortable: true }], rowKey: (row) => row.id, defaults: { limit: 25 }, }); engine.dispatch({ type: "setSearch", search: "ada" }); engine.rows("page"); // the rows that page shows ``` `CreateTableEngineOptions` is what you build one from. `data`, `columns`, `rowKey`, `locale`, `paginationMode`, `filterFn` and `getSearchText` stay live — change one and the engine follows. `tableId` and `defaults` are read once, because an identity and a seed cannot be retroactively different. `TableEngine` is the handle: | Member | What it gives you | | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `snapshot()` | A `TableSnapshot`: the current query state, the page it is showing, `lastPage`, `selectedIds`, `capabilities`, and the four revision counters. | | `rows(scope)` | A `TableRowScope` — `"page"`, `"visible"` or `"full"`. | | `dispatch(operation)` | A `TableOperation` a person performed: `setSort`, `setSearch`, `setPage`, `setLimit`, `setFilters`, `setGroupBy`, `setSelection`. | | `configure(patch)` | A `TableEngineConfigPatch` — controlled state a binding replays. Pass `{ silent: true }` to move without waking subscribers. | | `invalidate(axes, next)` | Tell it the data or columns changed. `invalidate(["data"])` with no new array re-derives from the rows it already holds. | | `subscribe(axes, listener)` | Wake on a `TableRevisionAxis` — `data`, `view`, `schema` or `policy` — and nothing else. | | `cellValue` / `rowByKey` / `rowKey` / `getColumn` | Read one cell, find a row, take a row's identity, look up a column. | | `stageCandidate` / `commitCandidate` / `discardCandidate` / `candidate` | Stage configuration and data privately, then publish or drop it. `candidate` reads the staged state; everything else reads the committed one. | | `tableId` / `dispose()` | The engine's identity, and releasing it. | `TableRevisions` carries those four counters. They are separate so a consumer can wake on the one it cares about: a virtualizer on `data`, a toolbar on `view`, an agent on `policy`. `snapshot().page` is the page actually on screen. Ask for page 9 of a table that shrank to three and it reports the last page, while `requestedPage` remembers what you asked for — so restoring the rows restores the page, instead of stranding a reader on page 1. ### Handing the engine to something that is not a table `createNeutralTable(engine, tableId, binding)` wraps one as a `NeutralTable`: the same rows and revisions plus an `operations` map saying which of them are actually wired right now. That is the shape `@adapttable/ai` reads, and the `NeutralTableBinding` is how a host tells it what the surrounding UI can do. An operation that stops being wired disappears from the map, so nothing is offered a capability the table can no longer perform. ## Features are imports A `` with `columns`, `rowKey` and a data tier searches, sorts and pages. Everything else — filters, grouping, editing, virtualization, bulk actions, the column menu — is a factory imported from a kit subpath and passed in `features`: ```tsx import { DataTable } from "@adapttable/mantine"; import { filters } from "@adapttable/mantine/filters"; import { grouping } from "@adapttable/mantine/grouping"; interface Person { id: string; name: string; team: string; } export function People({ people }: { people: Person[] }) { return ( row.id} features={[filters([]), grouping("team")]} /> ); } ``` The import is the switch: a table downloads a feature only when it names one. Host plugins are the same `TableFeature` type in the same array. [Feature composition](./features.md) lists every factory, the standard preset, and the plugin hooks. ## The two ways to use it 1. **Batteries-included** — `import { DataTable } from "@adapttable/"`. 2. **Headless** — `import { useDataTable } from "@adapttable/react"` and render your own markup with the returned prop-getters — see [headless rendering](./headless.md). --- # React table features — optional imports, presets and plugins ▶ **See it working:** [the Feature Lab](https://adapttable.orwamahmoud.com/react/demo/all-options/) — every opt-in, on every kit. Every opt-in is an import and one entry in `features`. The import is the switch, which is what lets a table pay only for what it named: ```tsx import { applyRowReorder } from "@adapttable/react"; import { DataTable } from "@adapttable/mantine"; import { rowReorder } from "@adapttable/mantine/row-reorder"; row.id} features={[ rowReorder((from, to) => setRows(applyRowReorder(rows, from, to))), ]} />; ``` A built-in factory and a host plugin are the same `TableFeature` type in the same array — that is the public plugin surface, not a parallel API. ## What the import buys A bundler follows imports, not prop values, so the import is the switch: a table downloads a feature's implementation when it names it, and not before. An adapter's `DataTable` carries the base contract — responsive rendering, loading, error and empty states, accessibility, sorting, search and pagination — for the weight the [FAQ's measured table](./faq.md#how-big-is-it--is-it-tree-shakeable) reports. Everything else arrives with its own entry. `` has no enabling props: `enableColumnMenu`, `bulkActions`, `contextMenu`, `commandPalette`, `statusBar`, `sidePanel`, `findInTable` and the rest are not `DataTable` props. The factory from the matching subpath turns a feature on — see [migrating from v2](./migrate-from-v2.md). ## Kit subpaths Every public adapter exports the same subpaths. **Kit** means the subpath binds the factory to that kit's own components; **headless** means it re-exports the `@adapttable/react/features` factory unchanged, because the feature draws no control of its own. | Import | Exports | Draws | | --------------------------------------- | ------------------------------------------------------------------------------------------------- | -------- | | `@adapttable//row-reorder` | `rowReorder` | kit | | `@adapttable//row-pinning` | `rowPinning` | headless | | `@adapttable//pinned-summary-rows` | `pinnedSummaryRows` | headless | | `@adapttable//cell-span` | `cellSpan` | headless | | `@adapttable//extra-rows` | `extraRows` | headless | | `@adapttable//row-appearance` | `rowAppearance` | headless | | `@adapttable//row-detail` | `rowDetail`, `nestedTable` | kit | | `@adapttable//nested-table` | `nestedTable` | kit | | `@adapttable//row-actions` | `rowActions` | headless | | `@adapttable//editing` | `editing`, `rowEditing`, `batchEditing`, `editHistory`, `dirtyIndicators`, `undoRedoButtons` | kit | | `@adapttable//batch-editing` | `batchEditing` | kit | | `@adapttable//grouping` | `grouping` — code-fixed grouping without the interactive panel | kit | | `@adapttable//grouping-panel` | `groupingPanel`, `GroupingPanel` — grouping headers + interactive panel | kit | | `@adapttable//tree` | `tree` | kit | | `@adapttable//virtualize` | `virtualize` | headless | | `@adapttable//column-menu` | `columnMenu` | kit | | `@adapttable//resizable-columns` | `resizableColumns` | headless | | `@adapttable//fit-columns` | `fitColumns` | headless | | `@adapttable//column-groups` | `collapsibleColumnGroups` | kit | | `@adapttable//column-selection` | `columnSelectionCheckbox` | kit | | `@adapttable//multi-sort` | `multiSort` | headless | | `@adapttable//filters` | `filters` (kit), `filterTypes` (headless) | kit | | `@adapttable//header-filters` | `headerFilters` | kit | | `@adapttable//saved-views` | `savedViews` | kit | | `@adapttable//export` | `exportCsv` | kit | | `@adapttable//cell-navigation` | `cellNavigation` | kit | | `@adapttable//selection-stats` | `selectionStats` | kit | | `@adapttable//status-bar` | `statusBar`, `selectionStats` | kit | | `@adapttable//find-in-table` | `findInTable` | kit | | `@adapttable//fullscreen` | `fullscreen` | kit | | `@adapttable//density` | `densityChooser` | kit | | `@adapttable//print` | `print` | kit | | `@adapttable//command-palette` | `commandPalette` | kit | | `@adapttable//context-menu` | `contextMenu` | kit | | `@adapttable//side-panel` | `sidePanel` | kit | | `@adapttable//bulk-actions` | `bulkActions` | kit | | `@adapttable//assistant` | `tableAssistant`, `TableAssistant` | kit | | `@adapttable//pivot` | `PivotPanel` and the pivot engine (`pivot`, `pivotTableModel`, …) | kit | | `@adapttable//preset` | `standardFeatures`, `StandardFeatureOptions` | kit | | `@adapttable//features` | the headless `@adapttable/react/features` surface: `applyTableFeatures`, `feature`, every factory | headless | `` is `mantine`, `mui`, `chakra`, `antd`, `radix`, `base-ui`, `shadcn`, or `unstyled`. `@adapttable//features` forwards `@adapttable/react/features` as it is, so a factory that draws kit UI — `columnMenu`, `filters`, `headerFilters`, `collapsibleColumnGroups`, `rowReorder`, `groupingPanel`, `exportCsv`, … — renders no controls when imported from there. Import each one from its own `@adapttable//`. The pivot engine stays a calculation — `import { pivot } from "@adapttable/core/pivot"` — not a `` prop. The kit `/pivot` subpath is the panel plus that engine, so a host that composes a pivot table still does it in one import. ## The standard preset — one import for a good table ```tsx import { DataTable } from "@adapttable/mantine"; import { standardFeatures } from "@adapttable/mantine/preset"; r.id} features={standardFeatures()} />; ``` With no arguments it composes the features that work with nothing else supplied: the Columns menu, the density chooser, CSV export, find-in-table (Ctrl/Cmd+F after a click in the table), fit-columns, the fullscreen toggle, [header filters](./header-filters.md), multi-sort, resizable columns and the status bar. Because `headerFilters()` is a member, filters from the preset's `filters` option open from the column headers (`filtersMode` resolves to `"header"`); compose the members individually for popover or drawer filters. Find draws no toolbar control by default. `standardFeatures({ findButton: true })` adds the Find control after Export: ```tsx features={standardFeatures({ findButton: true })} ``` Configurable preset members join only when you give them input — `grouping`, `bulkActions`, `filters` and `savedViews`. Features outside the preset append to the same ordinary array: ```tsx import { groupingPanel } from "@adapttable/mantine/grouping-panel"; [ ...standardFeatures({ bulkActions: [{ key: "delete", label: "Delete", onClick: remove }], filters: [{ key: "team", type: "select", options: teams }], savedViews: { storageKey: "people-table-views" }, }), groupingPanel("team"), ]; ``` One factory is callable with no arguments and is still NOT a member: the selection statistics feature needs a cell range — which needs `cellNavigation` — while arming a row-selection column on its own. Import it directly when you want it. The result is an ordinary array. Append to it, filter it, or replace an entry: ```tsx features={[...standardFeatures(), auditLog(), rowReorder(reorder)]} ``` Later entries win, so re-composing a member replaces it rather than doubling it, and a duplicate id warns in development. **The preset entry statically imports everything it can compose**, so its own bundle contains the configurable members whether or not you pass their options. That is the trade: one import instead of ten. Measured on MUI, the table alone is 72 kB gzipped and the same table with `standardFeatures()` composed is 127 kB. A table counting every byte imports the individual features it uses instead, and pays for those alone — `pnpm budget` measures both paths on every run. ## Types, and why no annotation is needed ```tsx import { groupingPanel } from "@adapttable/mantine/grouping-panel"; import { virtualize } from "@adapttable/mantine/virtualize"; ; ``` A factory whose configuration says nothing about rows returns a `StaticTableFeature` — the same feature whatever the table holds — and composes into any `` with no type argument. A factory that takes a row-typed callback returns `TableFeature` and infers the row from that callback: `rowReorder(handler)`, `editing(save)`. Put one in a table of a different row type and the compiler refuses, naming the feature's own row type. A host plugin is the same object: `feature("audit-log", { statusBar: true })`, or a `TableFeature` with `setup(host)` for live registration. ## Host plugins — `setup(host)` A plugin registers on the same `TableFeature` the factories return — `filterTypes`, an export writer, palette commands, context-menu items, a side panel — so the registration surface is one array, one host, with lifecycle via `onDispose` or a function returned from `setup`. ```tsx import type { TableFeature } from "@adapttable/mantine/features"; const currencyFilter: TableFeature = { id: "currency-filter", setup(host) { host.registerFilterType({ type: "currency", widget: "numberRange", ops: ["eq", "gt", "lt"], defaultOp: "eq", stateKeys: (def) => [def.key], match: () => true, chips: () => ({}), conditionToExtra: () => ({}), }); return () => { /* table unmounted, or `features` changed */ }; }, }; ; ``` The full filter-type contract is on [custom filter types](./custom-filter-types.md). Every seam is a method on `TableFeatureHost`. Built-in factories that carry extras (`filterTypes`, `exportCsv` with a writer, `commandPalette` with extra commands, `contextMenu` with extra items, `sidePanel` panels) call the same methods in `setup`, so a plugin is not a second API. | Host method | Same as | | -------------------------- | --------------------------------------------- | | `registerFilterType` | `filterTypes([spec])` | | `extendFilterType` | a custom feature's `host.extendFilterType(…)` | | `registerEditor` | `column.editor: { type: "custom", render }` | | `registerAggregator` | `aggregate({ key: fn })` | | `registerWriter` | `exportCsv({ writer })` | | `registerColumnMenuAction` | appended after the built-in Columns actions | | `registerPanel` | `sidePanel({ panels, … })` | | `registerCommand` | `commandPalette({ commands, … })` | | `registerContextMenuItems` | `contextMenu({ items })` | | `onDispose` | cleanup when the table unmounts | A named editor is a string `column.editor` that is not a built-in (`"text"`, `"number"`, …). `resolveCellEditor` turns it into `{ type: "custom", render }` so adapters keep one custom-editor path. A named aggregator is a string `aggregate()` looks up after the built-ins, when the mapper **runs** (inside the table), not when `aggregate()` is called in the parent. `registerPanel` appends to a composed `sidePanel()` dock — the feature still owns `open` / `onOpenChange`. Registrations add content to their matching composed feature; they do not pull command-palette, context-menu or side-panel chrome into the base table. The superseded `FilterTypeRegistry.register` / `extend` methods and the `filterTypes` enabling prop are not part of the v3 API. ## Features that own hooks — `provider` `apply` sets props and `setup(host)` registers values, but neither can add a React hook. Hooks must be called in the same order on every render, so a table that calls `useRowReorder` only when the feature is composed is not a table with an optional feature — it is a crash. That is why the enabling props never saved a byte: whatever the props said, the import was already in the graph. A component is the answer, because mounting and unmounting one is the legal way to add and remove hooks. A feature may carry a `provider` whose component wraps the table, calls whatever hooks it needs, and publishes the result under a typed key: ```tsx import { FeatureStateScope, featureStateKey, type TableFeature, useFeatureState, } from "@adapttable/react/adapter"; import { useEffect, useState } from "react"; export const AUDIT = featureStateKey<{ count: number }>("audit-log"); export const auditLog = (): TableFeature => ({ id: "audit-log", provider: { Provider: ({ children }) => { const [count, setCount] = useState(0); useEffect(() => subscribe(() => setCount((n) => n + 1)), []); return ( {children} ); }, }, }); ``` Anything under the table reads it with `useFeatureState`, which returns `undefined` when the feature is not composed — the ordinary answer for a table that does not have it, not an error: ```tsx const audit = useFeatureState(AUDIT); if (!audit) return null; return {audit.count} changes; ``` Both halves are typed: `featureStateKey` fixes what the provider must publish and what a reader gets back, so this is a contract rather than a bag of strings. ### Features that draw — `renders` State is half of a feature; the other half is what the reader sees. A feature fills named positions in the table, and the kit's own components are what it fills them with: ```tsx import { FeatureSlot, featureSlotKey, slotRender, } from "@adapttable/react/adapter"; export const STATUS_BAR = featureSlotKey<{ total: number }>("status-bar"); export const statusBar = (): TableFeature => ({ id: "status-bar", renders: [ slotRender(STATUS_BAR, ({ total }) => ), ], }); ``` The table computes the props and asks; it never learns what was drawn: ```tsx ``` `slotRender` is what keeps `total` typed at the call site; one feature can fill several positions that take different props. An unfilled slot renders nothing, so chrome around a position the reader does not have simply is not there. `useFeatureSlotFilled` answers when a wrapper must not be drawn around nothing. Several features may fill one position — a toolbar takes more than one control — so a slot keeps every answer and orders them by feature id, the same way providers nest. Fillers belong to the table that composed them, so two tables on a page never draw each other's controls. This is why a kit's pixels stay out of the base graph: the adapter's table asks for a position, and only the feature that was imported can answer. ### What the table guarantees - **Order comes from the ids, not from your array.** Providers nest in feature-id order, so `[groupingPanel("team"), auditLog()]` and `[auditLog(), groupingPanel("team")]` build the identical tree. Moving a line never remounts a provider or discards what it was holding. - **One provider per id.** A duplicate id warns in development and the last one wins, exactly as `apply` already resolves duplicates. - **A provider mounts when its feature arrives and unmounts when it leaves**, and its cleanup runs once. - **State belongs to its own table.** It travels by context, so two tables on a page — and a table nested in another table's row detail — never read each other's. A nested table shadows the outer value for its own subtree while everything the outer table published stays readable. This is the same `TableFeature` in the same `features` array. There is no second registry to learn and nothing global to collide over. ## Every factory `rowReorder` · `rowPinning` · `pinnedSummaryRows` · `cellSpan` · `extraRows` · `rowAppearance` · `rowDetail` · `nestedTable` · `editing` · `rowEditing` · `batchEditing` · `editHistory` · `dirtyIndicators` · `grouping` · `groupingPanel` · `tree` · `virtualize` · `columnMenu` · `resizableColumns` · `collapsibleColumnGroups` · `exportCsv` · `cellNavigation` · `findInTable` · `fullscreen` · `commandPalette` · `contextMenu` · `sidePanel` · `bulkActions` · `filters` · `filterTypes` · `headerFilters` · `savedViews` · `selectionStats` · `densityChooser` · `print` · `statusBar` · `undoRedoButtons` · `multiSort` · `fitColumns` · `columnSelectionCheckbox` · `rowActions` · `feature` (ad-hoc patch) · `applyTableFeatures` (the merge used by every adapter) · `useTableFeatures` (apply + `setup(host)`, the hook every adapter runs). Host helpers are exported from `@adapttable/react/adapter`: `featureHostOf` / `rememberFeatureHost` (the host of one table, never a sibling's) · `FeatureHostProvider` / `useFeatureHost` (hooks under that table) · `bindFeatureHostFn` (a mapper created outside the table still resolves names for the table that invokes it). --- # Client & server React table data — one TableSource API One ``, three ways to feed it — from "here's an array" to full query-library control. Search, sorting, filters, chips, and URL sync behave identically in every tier. ## Example ### 1. Frontend — `data` Pass the rows; the table filters, sorts, and pages them in memory. ```tsx // or import from "@adapttable/mui", "@adapttable/chakra", "@adapttable/antd", // "@adapttable/radix", "@adapttable/shadcn", "@adapttable/unstyled" — same props everywhere. import { DataTable } from "@adapttable/mantine"; interface Person { id: string; name: string; role: string; } const PEOPLE: Person[] = [ { id: "1", name: "Ada Lovelace", role: "Engineer" }, { id: "2", name: "Alan Turing", role: "Founder" }, { id: "3", name: "Grace Hopper", role: "Admiral" }, ]; export function PeopleTable() { return ( r.id} /> ); } ``` ### 2. Server — `data` + `total` + `loading` + `onQueryChange` Your API paginates; the table owns the query state and tells you when to fetch. ```tsx import { useState } from "react"; // or import from "@adapttable/mui", "@adapttable/chakra", "@adapttable/antd", // "@adapttable/radix", "@adapttable/shadcn", "@adapttable/unstyled" — same props everywhere. import { DataTable } from "@adapttable/mantine"; interface Person { id: string; name: string; role: string; } export function PeopleTable() { const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); return ( { setLoading(true); try { const params = new URLSearchParams({ page: String(query.page), limit: String(query.limit), search: query.search, }); // Forward `signal`: superseded requests abort at the source. const res = await fetch(`/api/people?${params}`, { signal }); const body = (await res.json()) as { items: Person[]; total: number }; setRows(body.items); setTotal(body.total); } finally { setLoading(false); } }} columns={[{ key: "name", sortable: true }, { key: "role" }]} rowKey={(r) => r.id} /> ); } ``` #### What the query carries, and how it grows Every server tier receives one consolidated `TableQuery`: ```ts { (page, limit, search, sortBy, sortDir, sortLevels, filters); } ``` That is the whole baseline, and it will not change. Capabilities beyond it — grouping, aggregates, nested filter trees, facet counts, cursor pagination — ride as **optional** fields that a source opts into by declaring what its endpoint can answer: ```tsx import { useServerData } from "@adapttable/react"; useServerData({ rows, total, // this endpoint can group and count; it cannot do the rest yet supports: { grouping: true, facets: true }, onQueryChange: async (query, { signal }) => { // query.groupBy → ["team"] when the user is grouping // query.facets → ["status"] when a filter wants distinct-value counts }, }); ``` Declare nothing and nothing changes: the query arrives with exactly the seven baseline fields, so an endpoint written before a capability existed keeps working untouched. Declare a capability and its field starts arriving. If the table wants something the source has not declared, the field is **omitted rather than sent and ignored** — a server should never receive a field it never agreed to read — and development logs which capability would unlock it. That warning is the intended way to discover the next thing your backend could do, not an error. | Field | Capability | Carries | | ------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `groupBy` | `grouping` | Grouping keys, outermost first | | `aggregates` | `aggregates` | `{ key, fn }` pairs to compute. Optional `supports.aggregateOperations` lists the ids the backend can answer; omit it and the five standard functions are assumed. Custom ids travel as strings, never as functions. | | `filterTree` | `filterTree` | Nested AND/OR condition tree | | `facets` | `facets` | Column keys needing distinct-value counts. The response returns the same keys as `facets` on the page (`PaginatedResponse.facets` / `PageSelector.facets`) — counts for the filtered set with each facet's own filter removed. | | `cursor` | `cursor` | Opaque cursor from the previous response | | `expandedIds` | `tree` | Open tree node ids, so the response can carry the children of every open branch | `cursor` and `expandedIds` need a source built with `useServerData` or `useQuerySource` and passed as `source`: they take the `nextCursor` and `expandedIds` options, which `` has no prop for. `supports`, `facetKeys` and `facets` are props on every kit's `` as well as options of those two hooks. The flat `filters` bag is always populated, including when `filterTree` is sent, so a server that only reads the simple form keeps working. ### 3. Full control — `source` Build a `TableSource` yourself — `useQuerySource` over TanStack Query (shown below; wrap your app in its `QueryClientProvider`), `useFrontendData` for headless in-memory use, or a [hand-rolled object that fulfils the contract](./custom-table-source.md). ```tsx import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; import type { PaginatedResponse, TableQueryParams } from "@adapttable/core"; // or import from "@adapttable/mui", "@adapttable/chakra", "@adapttable/antd", // "@adapttable/radix", "@adapttable/shadcn", "@adapttable/unstyled" — same props everywhere. import { DataTable, useQuerySource } from "@adapttable/mantine"; interface Person { id: string; name: string; role: string; } async function fetchPeople( params: Partial ): Promise> { const search = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value !== undefined) search.set(key, String(value)); } const res = await fetch(`/api/people?${search}`); return (await res.json()) as PaginatedResponse; } // Your query hook: fetch one page for the current params. function usePeopleQuery(params: Partial) { return useInfiniteQuery({ queryKey: ["people", params], queryFn: ({ pageParam }) => fetchPeople({ ...params, page: pageParam }), initialPageParam: params.page ?? 1, getNextPageParam: (last) => (last.hasNextPage ? last.page + 1 : undefined), placeholderData: keepPreviousData, }); } export function PeopleTable() { const source = useQuerySource({ usePaginatedQuery: usePeopleQuery }); return ( r.id} /> ); } ``` ## Explicit `mode` — when inference isn't what you meant The tier is inferred from what you pass (`data` alone → frontend; `data` + `onQueryChange` → server; `source` → full control). The optional `mode` prop pins it explicitly — and unlocks one combination inference cannot express: | I want… | Pass | `onQueryChange` acts as… | | ----------------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------- | | The table to fetch nothing; my handler runs every query | `mode="server"` (requires `onQueryChange`) | **the contract** — you fetch and hand back `data` + `total` | | The table to keep filtering/sorting/paging my `data`, but TELL me | `mode="frontend"` + `onQueryChange` | **a pure notification** — fires per committed change, not mount | | Today's inference exactly | omit `mode` | contract when present, nothing otherwise | `mode="server"` without `onQueryChange` does not compile; `mode` together with `source` dev-warns and `source` wins. ## How it works - Tier resolution is by what you pass: `source` wins; otherwise `onQueryChange` selects the server tier; otherwise `data` alone is the frontend tier. Mixing tiers dev-warns and uses `source`. - **Frontend**: search, the declarative-filter predicate, sorting, and page slicing all run in memory. Pagination defaults to `"auto"` — paged on desktop, infinite scroll on mobile. - **Server**: the table owns page, page size, debounced search, sort, and filter state (URL-synced), and emits ONE consolidated `TableQuery` — `{ page, limit, search, sortBy, sortDir, sortLevels, filters }` — per real change, **including once on mount with the URL-restored values**. Your only job is to fetch and hand back `data` + `total`. - Server queries are value-keyed (`stableKey`), so identical re-renders never re-fire the same query; when a newer query supersedes an in-flight one, the previous call's `signal` aborts — forward it to `fetch` and out-of-order responses die at the source. - **Full control**: every source builder returns the same [`TableSource`](./concepts.md) contract, so the table can't tell in-memory from server data — switch tiers without touching the UI. - Column `filter` shorthands and the `filters` array drive widgets, chips, and URL parsing in **all three tiers**; only the frontend tier also applies the row predicate (the other tiers receive `query.filters` instead). ## What a source can do — `capabilities` Some controls only work if the data layer behind them can answer. Exporting everything needs every row; grouping needs either the whole filtered set or a server that groups; "select all 2,431 matching" needs a source that can speak for rows that are not on screen. A source states what it supports: ```ts const source: TableSource = { ...rest, capabilities: { fullDataset: false, // one page at a time grouping: "server", // the API returns group rows selectAcrossPages: true, // it can act on the whole match set exportScope: "all", // it permits a wired full-export route totalCount: "exact", // `total` counts matches, not what has loaded }, }; ``` | Capability | Values | What it permits | Off means | | --------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | | `fullDataset` | `boolean` | Every row is reachable, not just the page on screen | The other four are decided independently | | `grouping` | `"client" \| "server" \| false` | `groupBy` groups in the browser, or renders the server's group rows | `groupBy` is ignored, and the status bar says why | | `selectAcrossPages` | `boolean` | The "select all N matching" banner after a full page is selected | Selection stays the rows on screen | | `exportScope` | `"all" \| "page"` | A source-owned `allFilteredRows` route may serve `scope: "all"`; it does not retrieve rows by itself | The Export button is disabled, with the reason on the control | | `totalCount` | `"exact" \| "loaded"` | `total` is the match count | `total` is what has arrived so far | | `aggregateOperations` | `readonly string[]` (optional) | The aggregate ids the backend computes | The five standard functions are assumed | **Omit `capabilities` and nothing changes.** The table reads the same answers off the source's shape, exactly as it always has: `allFilteredRows` present means the full dataset, `groups` present means the server grouped, a non-zero `total` means the count is real — which is why every source `useFrontendData` and `useQuerySource` build already answers correctly without declaring anything. Declare it when the shape is misleading in either direction: a paged source whose backend permits full exports (with the actual request wired on the export feature), or an in-memory slice that must not pretend to be the whole set. Capabilities describe support; they are not transport. `exportScope: "all"` without rows or a retrieval handler leaves Export all disabled. A source-owned route needs `allFilteredRows` to be present and the declaration to permit `"all"`. A host that wires `exportCsv.onExportAll`, `exportCsv.request`, or `exportCsv.fetchAll` supplies an independent executable route, so it can reach the rest whatever the source can or cannot retrieve. `onExportAll` is the server-built route: it receives the page-free current view, reports progress, and supports cancellation without loading rows into the table. See [browser and server-built exports](./exporting.md). ## Cache keys for TanStack Query and SWR Wiring the table to a query library means turning the emitted `TableQuery` into a cache key. Hand-rolling that fails in two ways that are hard to see: a key built from an object literal changes whenever `filters` is rebuilt, so the cache misses on every keystroke; and invalidation after a save either refetches the whole endpoint or only the page on screen. ```tsx import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { tableQueryKey, tableQueryBaseKey } from "@adapttable/core"; // `query` is the `TableQuery` the table emitted; `fetchPeople` returns a // `PaginatedResponse` for it. const queryClient = useQueryClient(); const infinite = useInfiniteQuery({ queryKey: tableQueryKey(query, { scope: "people" }), queryFn: ({ signal }) => fetchPeople(query, signal), initialPageParam: 1, getNextPageParam: (last) => (last.hasNextPage ? last.page + 1 : undefined), }); // after a write — every page of this view, nothing else queryClient.invalidateQueries({ queryKey: tableQueryBaseKey(query, { scope: "people" }), }); ``` - **`tableQueryBaseKey`** covers what decides _which_ rows: search, filters, sort, grouping, page size. - **`tableQueryKey`** appends _where_ in them the table is: page and cursor. The full key starts with the base key, so a library that matches by prefix — TanStack Query does — invalidates every page of a view from the base key alone. Both are stable across renders and ignore the order a filter object was built in, so an identical query always produces an identical key. Pass `scope` when a page shows more than one table, so they never share an entry. For SWR, hand `useSWR` the array directly or join it — the parts are strings. Neither library is imported or depended on here; these are plain arrays that happen to be exactly what both expect. The options shape is exported as `TableQueryKeyOptions`. ## Which requests actually fire `onQueryChange` fires per real change, not per render. Four guarantees: - **One request per query.** Queries are compared by value, so setting the same search term three times in a tick or an identical re-render collapses into a single call. Under StrictMode's development double-mount the handler runs twice and the first call's `signal` is already aborted, so forwarding `signal` to `fetch` leaves one live request. - **Setting a value it already holds is not a change.** No request fires. - **A superseded request aborts.** When a newer query replaces an in-flight one, the previous call's `signal` fires. Forward it to `fetch` and an out-of-order response dies at the source rather than overwriting fresher rows. - **Returning to a value re-requests it.** Typing `a` → `ab` → `a` fires three times. The first `a` was aborted the moment `ab` superseded it, so collapsing the third call would leave the table with nothing in flight and nothing to show. `refetch()` is the one deliberate exception: it asks for fresh data, so it fires even though the query has not changed. Using `useQuerySource` instead? Deduplication is your query library's, keyed the way you configured it, and these guarantees do not apply. ## Options | Prop | Type | Default | Description | | --------------- | ------------------------------------------------------------------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data` | `readonly TRow[]` | — | Frontend tier: all rows. Server tier: the current page, exactly as the server returned it. | | `total` | `number` | `0` | Server tier: total row count across all pages (drives the pager). | | `loading` | `boolean` | `false` | Server tier: request in flight (skeleton when no rows yet, subtle refresh indicator otherwise). | | `onQueryChange` | `(query: TableQuery, info: { signal: AbortSignal; key: string }) => void \| Promise` | — | Server tier: fired per consolidated query change, once on mount included. `info.key` identifies the request; echo it back as `responseKey` to say which one the rows answer. | | `responseKey` | `string` | — | The `info.key` of the request the current `data` answers (see [Row grouping](./row-grouping.md#server-tiers)). | | `aggregates` | `readonly QueryAggregate[]` | — | Developer defaults sent as `query.aggregates`; sent only when the source declares `supports.aggregates` (a `useServerData` / `useQuerySource` option). Reader overrides overlay this; Restore defaults returns here, not to the last response. | | `error` | `Error \| null` | `null` | Forwarded error to display. | | `source` | `TableSource` | — | Full control: a prebuilt source from `useFrontendData` / `useQuerySource` / your own. | | `mode` | `"frontend" \| "server"` | inferred | Pins the tier; `"server"` requires `onQueryChange`, `"frontend"` turns it into a notification (see [Explicit `mode`](#explicit-mode--when-inference-isnt-what-you-meant)). | ## Notes - **Picking a tier**: rows already in memory (up to a few thousand) → frontend. A paginated API and no query library → server. Caching, infinite scroll, prefetching, or an existing TanStack Query setup → `source` with `useQuerySource`. - The hooks behind the first two tiers — `useFrontendData` and `useServerData` — are exported for headless use; `useTableData` is the resolver that picks between them. - `useQuerySource` accepts `selectPage` (a `PageSelector` — project your own page shape to `{ rows, total? }` when it isn't `PaginatedResponse`), `baseParams` (static params merged into every call, e.g. a parent scope id), and `sanitizeParams`. Its query argument is typed structurally as `InfiniteQueryLike`, so TanStack Query stays a type-only peer. See [`examples/mui-query-source.tsx`](../examples/mui-query-source.tsx) for a complete runnable version. - `selectPage` is read through a ref: the projected rows recompute when fetched pages, pagination mode, or `selectorKey` change — not when the selector function's identity changes. Memoizing `selectPage` alone cannot trigger a re-projection. Pass the closed-over input as `selectorKey` when a memoized selector must re-run against unchanged fetched pages: ```tsx const selectPage = useCallback( (page: Page) => ({ rows: page.items.map((row) => ({ ...row, name: `${row.name}${suffix}` })), total: page.pagination.total, }), [suffix] ); const source = useQuerySource({ usePaginatedQuery, selectPage, selectorKey: suffix, }); ``` `selectorKey` accepts only a stable `string` or `number`. An unmemoized inline selector that closes over changing values and omits the key will keep showing the previous projection until the next fetch. - On the server tier, `source.refetch()` re-emits the current query; out-of-range pages and stale responses are handled for you via the abort signal. See it live in the [demo](https://adapttable.orwamahmoud.com/react/demo/). --- # React table headless rendering — useDataTable `useDataTable` from `@adapttable/react` is the table without markup. Give it a `TableSource` and your columns; it returns the rows to draw, the sort, search, pagination and selection state, and prop-getters that put the right roles, ARIA attributes and handlers on elements you render yourself. Search, sorting, paging and URL state work exactly as they do under a kit's ``. ```tsx import { type ColumnDef, useDataTable, useFrontendData, } from "@adapttable/react"; interface Person { id: string; name: string; team: string; salary: number; } const columns: ColumnDef[] = [ { key: "name", header: "Name", sortable: true }, { key: "team", header: "Team", sortable: true }, { key: "salary", header: "Salary", align: "end", sortable: true, accessor: (row) => row.salary.toLocaleString(), sortValue: (row) => row.salary, }, ]; export function PeopleTable({ people }: { people: Person[] }) { const source = useFrontendData({ data: people, columns, paginationMode: "paged", }); const table = useDataTable({ source, columns, rowKey: (row) => row.id }); return (
{table.columns.map((column) => ( ))} {table.rows.map((row, index) => ( {table.columns.map((column) => ( ))} ))}
{column.sortable ? ( ) : ( column.header )}
{table.getCellContent(column, row, index)}
{table.isEmpty &&

{table.labels.noData}

}
); } ``` That is a complete table: typing in the box searches after a 300 ms debounce, a header button cycles its column ascending → descending → cleared, and the page, search and sort live in the URL. ## How it works Two hooks, two jobs: 1. **A source** owns the data and the query state — rows, total, page, limit, search, sort, filters, grouping — and the setters that change them. It is a `TableSource`, the same contract every kit consumes; see [Concepts](./concepts.md#the-tablesource-contract). 2. **`useDataTable`** reads the source and the columns and derives what rendering needs: the visible columns for the current layout, the debounced search value, pagination figures, selection, filter chips and the prop-getters. `useDataTable` never renders and never fetches. Everything it returns is a function of the source and the options, so the same hook drives a ``, a card list, a virtualized grid, or a design system's own components. Columns are the React `ColumnDef` a kit takes. A column without a `header` gets one humanized from its `key`, and a column without an `accessor` or `Cell` reads the row by its key, exactly as under ``. ## Building a source Every source builder returns a `TableSource`, and `useDataTable` cannot tell them apart. Pick by where the rows live — the tiers themselves are covered in [Data tiers](./data-tiers.md). ### In memory — `useFrontendData` ```tsx import { useFrontendData } from "@adapttable/react"; const source = useFrontendData({ data: people, columns }); ``` Search, sort and paging run in memory. Pass `columns` so sorting can read each column's `sortValue`; `getSearchText`, `getSortValue` and `filterFn` override the defaults, and `getRowId` defaults to `String(row.id)`. `paginationMode` defaults to `"auto"` — infinite on viewports at or below 768 px, paged above — so set `"paged"` or `"infinite"` when your markup renders only one of them. ### Your own fetch — `useServerData` ```tsx import { useServerData } from "@adapttable/react"; import { useState } from "react"; interface Person { id: string; name: string; } export function usePeopleSource() { const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); return useServerData({ rows, total, loading, paginationMode: "paged", onQueryChange: async (query, { signal }) => { setLoading(true); try { const params = new URLSearchParams({ page: String(query.page), limit: String(query.limit), search: query.search, }); const res = await fetch(`/api/people?${params}`, { signal }); const body = (await res.json()) as { items: Person[]; total: number }; setRows(body.items); setTotal(body.total); } finally { setLoading(false); } }, }); } ``` `onQueryChange` fires with one consolidated `TableQuery` whenever the query changes, including once on mount with the URL-restored values, and aborts the previous call's `signal` when a newer query supersedes it. `supports` opts the query into the optional fields listed in [What the query carries](./data-tiers.md#what-the-query-carries-and-how-it-grows); `facetKeys`, `aggregates`, `nextCursor` and `expandedIds` supply their values. ### A query library — `useQuerySource` `useQuerySource({ usePaginatedQuery })` wraps your `useInfiniteQuery`-style query hook into the same contract: it flattens pages in infinite mode, shows the latest page in paged mode, and clamps out-of-range pages. The full TanStack Query example is in [Data tiers — Full control](./data-tiers.md#3-full-control--source). ## `useDataTable` options | Option | Type | Default | Description | | ----------------------- | ----------------------------------- | -------------- | --------------------------------------------------------------------------------------- | | `source` | `TableSource` | required | The data and query state. | | `columns` | `ColumnDef[]` | required | Column definitions. | | `rowKey` | `(row: TRow) => string` | required | Stable row identity; the React key and, by default, the selection id. | | `tableLabel` | `string` | `labels.table` | The table's accessible name. | | `labels` | `TableLabels` | English | Label overrides, merged over the defaults. | | `locale` | `string` | — | Resolves per-column `i18n` data paths for bare-key columns. | | `dir` | `"ltr" \| "rtl"` | `"ltr"` | Written to the table element by `getTableProps`. | | `forceMobile` | `boolean` | `false` | Use the mobile column set; reported back as `isMobile`. | | `mobileIdentityColumns` | `number` | — | Deprecated and ignored; removed in v4. `hideOnMobile` decides the mobile column set. | | `searchDebounceMs` | `number` | `300` | Delay between typing and `source.setSearch`. | | `multiSort` | `boolean` | `false` | Shift-press on a sort button adds or cycles a sort level instead of replacing the sort. | | `bulkActions` | `BulkAction[]` | — | Any action turns selection on. | | `selectionGetId` | `(row: TRow) => string` | `rowKey` | Selection id when it differs from the row key. | | `selectedIds` | `readonly string[]` | — | Controlled selection. | | `onSelectedIdsChange` | `(ids: string[]) => void` | — | Change handler for the controlled selection. | | `filterLabels` | `Record` | — | Chip label per filter key; drives `filterChips`. | | `fitColumns` | `boolean` | `false` | Share the container width: `flex` columns get a percentage width. | | `columnWidths` | `Record` | — | User widths (for example from a column layout); they win over `width` and `flex`. | ## The result | Field | What it is | | --------------------------------------- | ----------------------------------------------------------------------------------------------- | | `rows` | The rows for the current slice — the page, or everything loaded in infinite mode. | | `columns` | The columns to render for the current layout, with headers and accessors resolved. | | `isEmpty` | `true` when there are no rows and the first load is not in progress. | | `isMobile` | The `forceMobile` value. | | `labels` | Every label, defaults merged with your overrides. | | `dir` | The resolved direction. | | `pagination` | `{ totalPages, safePage, fromIndex, toIndex }`; the indices are 1-based and `0` when empty. | | `sortBy` / `sortDir` | The primary sort. | | `toggleSort(key)` | Advance a column through ascending → descending → cleared. | | `searchValue` / `setSearchValue` | The input's immediate value and its setter; the source receives it after the debounce. | | `sortByOptions` | `{ value, label }` for every sortable column with a text label — the options for a sort select. | | `selection` | `SelectionState` when `bulkActions` is non-empty, else `null`. | | `filterChips` | Removable `{ key, label, onRemove }` chips for active filters that have a `filterLabels` entry. | | `activeFilterCount` | `filterChips.length`. | | `source` | The source you passed, for `setPage`, `setLimit`, `fetchNextPage` and the rest. | | `getRowKey(row)` | The row's React key — `rowKey`, kept out of `getRowProps`. | | `getCellContent(column, row, rowIndex)` | The column's `Cell` component when set, else its `accessor` value, else `null`. | Selection is keyed by id, so it survives page, sort and page-size changes; it resets when the search, the filters or the grouping change the result set. ## Prop-getters Each getter returns a plain object to spread on an element. Pass your own props as the last argument and they are merged: `className` values are joined, `style` objects are merged, event handlers (`on` followed by a capital letter) both run — the getter's first — and any other key you pass replaces the getter's value. ```tsx openPerson(row.id), })} /> ``` | Getter | Returns | | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `getTableProps(props?)` | `role: "table"`, `dir`, `aria-label` (`tableLabel`, else `labels.table`). | | `getHeaderRowProps(props?)` | `role: "row"`. | | `getHeaderCellProps(column, props?)` | `role: "columnheader"`, `scope: "col"`, `aria-sort` (`"ascending"`, `"descending"` or `"none"` on sortable columns, absent otherwise), `data-sort-index`, `data-column-key`, and `style`. | | `getSortButtonProps(column, props?)` | `type: "button"`, `disabled` (`true` when the column is not sortable), `onClick`, `data-sort-index`, and `aria-label` (`"Sort by:
"`). | | `getRowProps(row, index, props?)` | `role: "row"`, `data-adapttable-part: "row"`, `data-row-id`, `data-index` (the `index` you pass), and `aria-selected` while selection is on. Never a `key`. | | `getCellProps(column, props?)` | `role: "cell"`, `data-column-key`, and `style`. | | `getSearchInputProps(props?)` | `type: "search"`, `role: "searchbox"`, `value`, `placeholder` (`labels.searchPlaceholder`), `aria-label` (`labels.search`), and `onChange`. | The `style` on header and body cells carries `textAlign` from the column's `align` (`start`, `center` or `end`, so it follows the writing direction) and, when the column states any, `width`, `minWidth` and `maxWidth` — the user's width from `columnWidths` first, then the column's `width`, then its `flex` share under `fitColumns`. `data-sort-index` is the column's 1-based place in a multi-column sort and is present only while it is sorted. The sort button's `onClick` accepts the click event: with `multiSort: true`, a Shift-press toggles the column's level in the sort chain instead of replacing the sort. `data-index` is also the attribute a virtualizer measures by, which is why `getRowProps` takes the index: pass the row's position in the source rows, not its position on screen. ## Selection Pass `bulkActions` and `selection` becomes a `SelectionState`: ```tsx import { type ColumnDef, useDataTable, useFrontendData, } from "@adapttable/react"; interface Person { id: string; name: string; } const columns: ColumnDef[] = [{ key: "name" }]; export function SelectablePeople({ people, onArchive, }: { people: Person[]; onArchive: (ids: string[]) => void; }) { const source = useFrontendData({ data: people, columns }); const table = useDataTable({ source, columns, rowKey: (row) => row.id, bulkActions: [{ key: "archive", label: "Archive", onClick: onArchive }], }); const selection = table.selection; return (
{table.columns.map((column) => ( ))} {table.rows.map((row, index) => ( {table.columns.map((column) => ( ))} ))}
selection?.toggleAll()} /> {column.header}
selection?.toggle(row.id)} /> {table.getCellContent(column, row, index)}
); } ``` `selection` also carries `selectedIds`, `selectedCount`, `clear`, `replace`, `selectAllMatching` and `allMatching` for a "select every match" banner, and `headerState` (`"all"`, `"some"` or `"none"`) for an indeterminate header box. `useBulkActionRunner` runs an action through a confirm handler; see [Selection & bulk actions](./selection.md). ## Mobile cards `useDataTable` does not watch the viewport. Read it with `useIsMobile()` — a `(max-width: 768px)` media query by default, `useIsMobile(px)` for another breakpoint — and pass the answer as `forceMobile`. `columns` then drops the columns marked `hideOnMobile`, and mobile-only columns (`hideOnDesktop`) appear. ```tsx import { type ColumnDef, useDataTable, useFrontendData, useIsMobile, } from "@adapttable/react"; interface Person { id: string; name: string; team: string; email: string; } const columns: ColumnDef[] = [ { key: "name", header: "Name", sortable: true }, { key: "team", header: "Team", sortable: true }, { key: "email", header: "Email", hideOnMobile: true }, ]; export function People({ people }: { people: Person[] }) { const isMobile = useIsMobile(); const source = useFrontendData({ data: people, columns, paginationMode: "paged", }); const table = useDataTable({ source, columns, rowKey: (row) => row.id, forceMobile: isMobile, }); if (!table.isMobile) { // …the from the first example return null; } return (
    {table.rows.map((row, index) => (
  • {table.columns.map((column) => (
    {column.mobileLabel ?? column.header}
    {table.getCellContent(column, row, index)}
    ))}
  • ))}
); } ``` A card has no header to click, so sorting moves to a select built from `sortByOptions`. `getRowProps` returns `role: "row"`, which belongs inside a table; a list item takes its key from `getRowKey` instead. Kit adapters make the same switch at the same 768 px default — see [Mobile cards](./mobile.md). ## Virtualization `useTableVirtualization` windows any list of rows: it returns the slice to mount and the space above and below it. It is exported from `@adapttable/react` and uses `@tanstack/react-virtual`, a dependency of that package. ```tsx import { type ColumnDef, useDataTable, useFrontendData, useTableVirtualization, } from "@adapttable/react"; import { useRef } from "react"; interface Reading { id: string; sensor: string; value: number; } const columns: ColumnDef[] = [ { key: "sensor", header: "Sensor", sortable: true }, { key: "value", header: "Value", align: "end", accessor: (row) => row.value, }, ]; export function Readings({ readings }: { readings: Reading[] }) { const scrollRef = useRef(null); const source = useFrontendData({ data: readings, columns, paginationMode: "infinite", }); const table = useDataTable({ source, columns, rowKey: (row) => row.id }); const virtual = useTableVirtualization({ rows: table.rows, rowKey: table.getRowKey, enabled: true, estimateSize: 40, getScrollElement: () => scrollRef.current, onEndReached: source.fetchNextPage, }); return (
{table.columns.map((column) => ( ))} {virtual.paddingTop > 0 && ( )} {virtual.rows.map(({ row, index, key }) => ( {table.columns.map((column) => ( ))} ))} {virtual.paddingBottom > 0 && ( )}
{column.header}
{table.getCellContent(column, row, index)}
); } ``` With `getScrollElement` the window tracks that element; without it, it tracks the page, and `scrollMargin` is the list's offset from the top of the document. Each mounted row is measured through `measureElement`, which reads the `data-index` that `getRowProps` writes. `onEndReached` fires once each time the window reaches the last loaded row, which is where an infinite source loads more. | Option | Type | Default | Description | | ------------------ | --------------------------------------- | -------- | --------------------------------------------------------------------------------- | | `rows` | `readonly TRow[]` | required | The rows to window — usually `table.rows`. | | `rowKey` | `(row: TRow) => string` | required | Stable row key. | | `enabled` | `boolean` | `false` | Off returns every row with no spacers, so one render path serves both cases. | | `estimateSize` | `number \| ((index: number) => number)` | `56` | Row height estimate in px, before measurement. | | `overscan` | `number` | `8` | Rows mounted beyond each edge of the visible window. | | `getScrollElement` | `() => Element \| null` | — | The scroll box to track; omit to track the page. | | `scrollMargin` | `number` | `0` | Page mode only: the list's offset from the top of the document. | | `onEndReached` | `() => void` | — | Called when the window reaches the last row. | | `expandable` | `boolean` | `false` | Rows carry a detail row; each pair is measured together through `measureRowPair`. | It returns `{ enabled, rows, paddingTop, paddingBottom, measureElement?, measureRowPair? }`, where each entry of `rows` is `{ row, index, key, virtualItem? }`. A windowed table mounts a fraction of its rows, so tell assistive technology the real size: set `aria-rowcount` on the table and `aria-rowindex` on each row, as the kit adapters do — see [Virtualization](./virtualization.md) and [Accessibility](./accessibility.md). ## URL state The source builders keep their state in the query string through `useTableUrlState`, so a headless table has shareable, reload-safe URLs with no extra code. All three builders take the same URL options: | Option | Default | Description | | ----------------- | ----------- | ----------------------------------------------------------------------------------- | | `urlSync` | `true` | `false` keeps the state in component memory instead of the URL. | | `urlKey` | — | Namespace for this table's params (`left.q`, `left.page`, …) when a URL holds two. | | `urlAdapter` | History API | A `UrlStateAdapter` for your router. | | `defaults` | — | Initial `page`, `limit`, `search`, sort and `extra` values while the URL is silent. | | `numberExtraKeys` | — | Extra-filter keys parsed as numbers. | | `arrayExtraKeys` | — | Extra-filter keys parsed as comma-separated arrays. | State that is not a query field has its own hook, each returning a value and a change handler to wire into your controls: | Hook | Returns | | -------------------------- | ------------------------------------------------------------------- | | `useColumnLayoutUrlState` | `{ layout, onLayoutChange }` — hidden, order, pinned, widths, names | | `useDensityUrlState` | `{ density, onDensityChange }` | | `useGroupCollapseUrlState` | `{ collapsedGroupIds, onCollapsedGroupIdsChange }` | | `useRowPinningUrlState` | `{ pinnedRowIds, onPinnedRowIdsChange }` | Each takes `urlKey`, `urlSync` and `urlAdapter`. `createHistoryAdapter()` and `createMemoryAdapter(initialSearch?)` build adapters; router adapters for react-router, TanStack Router and Next.js, the param names, and the format guarantees are in [URL state](./url-state.md). ## Headless or an adapter `useDataTable` is the right level when the markup is yours: a design system the kits do not cover, a card-first layout, or a table embedded in a larger component. The prop-getters carry the semantics; the controls around them — pagination, filter forms, column menus, toolbars — are yours to draw. When the goal is a complete table for a UI kit that AdaptTable does not ship, with the feature factories, slots and every built-in behaviour, write an adapter on the `@adapttable/react/adapter` builder tier instead — see [Building an adapter](./building-an-adapter.md). Using a kit AdaptTable already supports, `` from `@adapttable/` is the shorter path; its `classNames`, `slots` and `renderCard` cover most visual changes without leaving it ([Customization](./customization.md)). ## Notes - `useDataTable` does not apply filters on its own. `useFrontendData` filters through `filterFn` and `filterTreeFn`; server sources send the filter state in the query. Set filter values with `source.setExtra`. - `getTableProps` always returns `role: "table"`. Keyboard grid navigation (`role="grid"`, roving focus, ranges) is `useGridFocus`; see [Cell navigation](./cell-navigation.md#headless). - Infinite mode without virtualization pairs with `useInfiniteScroll`, a sentinel ref that calls `fetchNextPage` as the end of the list comes into view; see [Pagination modes](./concepts.md#pagination-modes). - Export works headless too: `downloadTableCsv({ source, columns, writer })` from `@adapttable/core` builds and downloads the file the export button would, with any writer — see [Excel (XLSX) export](./export-xlsx.md). --- # Custom React table data source — the TableSource contract Every `` renders one `TableSource`: the rows to show, the view state that produced them, and the setters that change it. The built-in builders — `useFrontendData`, `useServerData`, `useQuerySource` — each return one. When none of them fits your data layer, build the object yourself and pass it as `source`; the table cannot tell the difference. **Related:** [Data tiers](./data-tiers.md) · [Concepts](./concepts.md) · [Realtime](./realtime.md) · [API](./api.md#source-capabilities) ## Pick a builder first Most data fits a builder, and a builder already handles the edge cases this page lists. | Your data | Use | | ---------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Rows already in memory | `data` alone, or `useFrontendData` headless — [data tiers](./data-tiers.md#1-frontend--data) | | Rows pushed into memory by a socket or poll | `data` plus `applyRowPatches` / `useRowPatchStream` — [realtime](./realtime.md) | | A paginated endpoint you call yourself | `onQueryChange`, or `useServerData` headless | | TanStack Query `useInfiniteQuery`, caching, prefetch | `useQuerySource` — [full control](./data-tiers.md#3-full-control--source) | A hand-rolled source fits when: - your data layer already speaks its own protocol — a socket that answers queries and pushes invalidations, an SDK with its own paging object — and mapping it onto the contract is shorter than routing it through `onQueryChange`; - the view state (page, sort, filters) must live in a store you already own, because other parts of the app read and write it; - a test or story needs a fixed source with exact flags. ## Example — a socket that answers queries The server receives a query over a WebSocket, replies with one page, and sends `changed` when the data moves. `useTableUrlState` supplies the whole state half of the contract; the hook adds the data half. ```tsx import { useEffect, useRef, useState } from "react"; // or import from "@adapttable/mui", "@adapttable/chakra", "@adapttable/antd", // "@adapttable/radix", "@adapttable/shadcn", "@adapttable/unstyled" — same exports everywhere. import { DataTable, type TableSource, useTableUrlState, } from "@adapttable/mantine"; interface Trade { id: string; symbol: string; price: number; } /** What the server sends back over the socket. */ type ServerMessage = | { type: "page"; requestId: number; rows: Trade[]; total: number } | { type: "error"; requestId: number; message: string } | { type: "changed" }; /** Paged only: there is never a next page to append. */ function noNextPage(): void {} export function useTradeSource(url: string): TableSource { // The state half: page, limit, search, sort, filters, grouping and every // setter, URL-synced, with each visible-row change resetting to page 1. const state = useTableUrlState({ urlKey: "trades" }); const socketRef = useRef(null); const latestRequest = useRef(0); const [open, setOpen] = useState(false); const [revision, setRevision] = useState(0); const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); const [loaded, setLoaded] = useState(false); const [fetching, setFetching] = useState(false); const [error, setError] = useState(null); useEffect(() => { const socket = new WebSocket(url); socketRef.current = socket; socket.onopen = () => setOpen(true); socket.onclose = () => setOpen(false); socket.onerror = () => { setFetching(false); setError(new Error("The trades feed is unavailable.")); }; socket.onmessage = (event: MessageEvent) => { const message = JSON.parse(event.data) as ServerMessage; if (message.type === "changed") { // The server says the data moved: ask for the same view again. setRevision((n) => n + 1); return; } // A reply to a superseded query is dropped. if (message.requestId !== latestRequest.current) return; setFetching(false); if (message.type === "error") { setError(new Error(message.message)); return; } setRows(message.rows); setTotal(message.total); setError(null); setLoaded(true); }; return () => { socket.close(); socketRef.current = null; }; }, [url]); const { page, limit, search, sortBy, sortDir, extra } = state; useEffect(() => { const socket = socketRef.current; if (!open || !socket) return; latestRequest.current += 1; setFetching(true); socket.send( JSON.stringify({ type: "query", requestId: latestRequest.current, page, limit, search, sortBy, sortDir, filters: extra, }) ); }, [open, revision, page, limit, search, sortBy, sortDir, extra]); return { ...state, rows, total, isLoading: !loaded, isFetching: fetching, isFetchingNextPage: false, hasNextPage: false, fetchNextPage: noNextPage, error, refetch: () => setRevision((n) => n + 1), paginationMode: "paged", capabilities: { fullDataset: false, grouping: false, selectAcrossPages: true, exportScope: "page", totalCount: "exact", }, }; } export function TradesTable() { const source = useTradeSource("wss://example.com/trades"); return ( row.id} /> ); } ``` ## How it works - `source` wins tier resolution: the table uses the object as it is and does no filtering, sorting or paging of its own. Passing `data` or `onQueryChange` alongside it dev-warns. - `useTableUrlState` returns every state field and every setter the contract needs (`UseTableUrlStateResult` extends `TableStateMutators`), so spreading it covers that half. Pass `urlSync: false` to keep the same state in memory instead of the URL. - The source re-sends its query whenever a state value changes, and on `refetch()` or a server `changed` message. Each request carries an id, and a reply to an older id is dropped, so a slow answer never overwrites a newer one. - `isLoading` is true until the first page arrives and never again; `isFetching` is true whenever a request is in flight. The table draws the skeleton while `isLoading` holds and no rows exist, and a non-blocking refresh indicator for every later request. - `capabilities` says this source serves one page at a time and counts the full match set. Grouping is off, so a `groupBy` in the URL is ignored and the status bar says why. ## The contract `TableSource` is exported from `@adapttable/core` and re-exported by every kit. The members below are required. ### Data | Member | Type | What the table does with it | | -------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `rows` | `readonly TRow[]` | Renders them — the current page, or every loaded row in infinite mode. | | `total` | `number` | Drives the pager and the "select all N matching" banner. | | `isLoading` | `boolean` | First load only. With no rows, the body renders the skeleton. A background refresh never raises it again. | | `isFetching` | `boolean` | Any request in flight. Without `isLoading` or `isFetchingNextPage`, the table shows its refresh indicator and `aria-busy`. | | `isFetchingNextPage` | `boolean` | An append started by `fetchNextPage` is in flight. Always `false` in paged mode. | | `hasNextPage` | `boolean` | Infinite mode shows Load more and arms the scroll sentinel. Always `false` in paged mode. | | `fetchNextPage` | `() => void` | Appends the next page. A no-op in paged mode, while an append is in flight, or when the data is exhausted. | | `error` | `Error \| null` | Non-null replaces the body with the error state and hides the pager. | | `paginationMode` | `"paged" \| "infinite"` | The resolved mode — never `"auto"`. Paged shows the footer pager; infinite shows Load more. | ### View state | Member | Type | Meaning | | -------------- | ------------------------------ | ---------------------------------------------------------------------------------- | | `page` | `number` | Current 1-based page. | | `limit` | `number` | Current page size. | | `defaultLimit` | `number` | The size the rows-per-page list keeps offering after the reader picks another one. | | `search` | `string` | Committed search term. | | `sortBy` | `string \| undefined` | Active sort column key. | | `sortDir` | `"asc" \| "desc" \| undefined` | Active sort direction. | | `sortLevels` | `readonly SortLevel[]` | The multi-sort chain; empty unless multi-sort is in use. | | `extra` | `ExtraFilters` | The filter bag: `Record`. | | `groupBy` | `string \| undefined` | Row-grouping keys, comma-separated. | ### Setters — `TableStateMutators` Every setter that changes which rows are visible also resets to page 1. | Member | Signature | Obligation | | ----------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------- | | `setPage` | `(next: number) => void` | Set the 1-based page. The only setter that does not reset the page. | | `setLimit` | `(next: number) => void` | Set the page size; reset to page 1. | | `setSort` | `(key: string \| undefined, dir?: SortDirection) => void` | Set or clear the single-column sort; reset the multi-sort chain and the page. | | `toggleSortLevel` | `(key: string) => void` | Cycle the key in the chain: absent → asc → desc → removed. New keys append at the end. | | `setSearch` | `(next: string) => void` | Set or clear the search term; reset to page 1. | | `setExtra` | `(key: string, value: FilterValue) => void` | Set one filter; reset to page 1. | | `setExtras` | `(updates: ExtraFilters) => void` | Set several filters in one commit; reset to page 1. | | `clearExtras` | `() => void` | Clear every filter; keep search and sort; reset to page 1. | | `clearAll` | `() => void` | Clear search, sort, grouping, page and every filter in one commit. | | `setGroupBy` | `(key: string \| undefined) => void` | Set or clear the grouping keys; reset to page 1. | The table's Clear filters action calls `clearExtras()`, then `setFilterTree?.(undefined)`. The empty state reads "no results" rather than "no data" when a filter chip is active, `extra` has a key, or `search` is non-empty. ## Optional members A source adds these when it can answer them. Leaving one out turns off what it would have unlocked; nothing else changes. | Member | Add it when | | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `refetch` | The source can re-run its fetch. The error state offers Retry only when this exists, and shows it as retrying while `isFetching` is true. | | `allFilteredRows` | Every row matching search, filters and sort is in hand, unpaginated. Client grouping, export of every matching row and select-across-pages read it. | | `allSearchedRows` | Rows after search, before filters. Facet counts start here so a checklist can exclude its own filter. | | `facets` | You computed distinct-value counts per filter key (`FacetMap`) — on a server, the counts for `query.facets`. | | `filterTree` / `setFilterTree` | The source applies a nested AND/OR tree ([advanced filters](./filter-tree.md)). | | `capabilities` | The shape of the source would mislead the table — see below. | | `groups` | The server computed the groups (`QueryGroupRow[]`) for the current `groupBy`. The table renders them instead of grouping the one page it holds. | | `groupAggregations` / `queryAggregates` | With server groups: the operations the rows on screen were computed with, and the developer's original `query.aggregates`. | | `aggregateOperations` | The backend names the aggregate ids it computes. Omitted, the five standard functions are assumed. | | `honorsAggregates` | The source computes the `query.aggregates` it receives. On server groups the reader's aggregate controls are enabled only when this is `true`. Frontend sources omit it. | | `groupAggregateOverrides` / `setGroupAggregateOverrides` | The source keeps the reader's per-session aggregate choices. `useTableUrlState` provides both. | | `initializeGroupBy` | Set grouping only when no grouping state exists yet, so an explicit empty value still wins. `useTableUrlState` provides it. | | `tableEngine` | The source is backed by a `TableEngine` — see [the engine and revisions](#the-engine-and-revisions). | ## State that lives in your own store When page, sort and filters must live in a store you own, implement the setters yourself. This hook keeps the view in React state; the same functions map onto any store's actions. ```ts import { useState } from "react"; import type { ExtraFilters, SortLevel, TableSource, TableStateMutators, } from "@adapttable/core"; /** The view a table reads and writes, held wherever your app keeps state. */ export type ViewState = TableStateMutators & Pick< TableSource, | "page" | "limit" | "defaultLimit" | "search" | "sortBy" | "sortDir" | "extra" | "groupBy" >; interface View { page: number; limit: number; search: string; sortLevels: readonly SortLevel[]; extra: ExtraFilters; groupBy: string | undefined; } const DEFAULT_LIMIT = 25; const INITIAL: View = { page: 1, limit: DEFAULT_LIMIT, search: "", sortLevels: [], extra: {}, groupBy: undefined, }; /** absent → asc → desc → removed; a new key joins the end of the chain. */ function cycleLevel( levels: readonly SortLevel[], key: string ): readonly SortLevel[] { const current = levels.find((level) => level.key === key); if (!current) return [...levels, { key, dir: "asc" }]; if (current.dir === "asc") { return levels.map((level) => level.key === key ? { key, dir: "desc" } : level ); } return levels.filter((level) => level.key !== key); } export function useViewState(): ViewState { const [view, setView] = useState(INITIAL); // Every change to which rows are visible starts again at page 1. const narrow = (next: (prev: View) => Partial) => setView((prev) => ({ ...prev, ...next(prev), page: 1 })); const head = view.sortLevels[0]; return { ...view, defaultLimit: DEFAULT_LIMIT, sortBy: head?.key, sortDir: head?.dir, setPage: (page) => setView((prev) => ({ ...prev, page })), setLimit: (limit) => narrow(() => ({ limit })), setSort: (key, dir = "asc") => narrow(() => ({ sortLevels: key ? [{ key, dir }] : [] })), toggleSortLevel: (key) => narrow((prev) => ({ sortLevels: cycleLevel(prev.sortLevels, key) })), setSearch: (search) => narrow(() => ({ search })), setExtra: (key, value) => narrow((prev) => ({ extra: { ...prev.extra, [key]: value } })), setExtras: (updates) => narrow((prev) => ({ extra: { ...prev.extra, ...updates } })), clearExtras: () => narrow(() => ({ extra: {} })), clearAll: () => setView((prev) => ({ ...INITIAL, limit: prev.limit })), setGroupBy: (groupBy) => narrow(() => ({ groupBy })), }; } ``` Swap `useTableUrlState({ urlKey: "trades" })` in the socket example for `useViewState()` and the rest of the source is unchanged. `sortBy` and `sortDir` read the head of the chain, so single-column and multi-column sorting stay one state. ## `capabilities` — what the source can do Some controls need more than one page: exporting every row, grouping, "select all 2,431 matching". `sourceCapabilities(source)` in `@adapttable/core` is the one place the table decides what a source supports. A declared `capabilities` object wins outright — it is used whole, not merged. Without one, the answer is read off the source's shape: | Capability | Inferred as | | ------------------- | ------------------------------------------------------------------------------------------------ | | `fullDataset` | `true` when `allFilteredRows` is present. | | `grouping` | `"server"` when `groups` is present; otherwise `"client"` when `fullDataset`; otherwise `false`. | | `selectAcrossPages` | `true` when `fullDataset`, or when `total` is greater than zero. | | `exportScope` | `"all"` when `fullDataset`; otherwise `"page"`. | | `totalCount` | `"exact"` when `fullDataset`, or when `total` is greater than zero; otherwise `"loaded"`. | Declare `capabilities` when the shape says the wrong thing: a paged source whose backend can export or select everything, or an in-memory slice that must not pose as the whole set. A declaration describes support; it does not move data. `exportScope: "all"` without `allFilteredRows` still needs an export route on the export feature — see [what a source can do](./data-tiers.md#what-a-source-can-do--capabilities) and [browser and server-built exports](./exporting.md). ## The engine and revisions `tableEngine` is optional, and only `useFrontendData` sets it among the builders. When a source carries one, the table wraps it with `createNeutralTable(engine, engine.tableId, binding)` and publishes the resulting `NeutralTable` — the shape `@adapttable/ai` reads — alongside the rows it renders. A source without an engine renders exactly the same. Revisions belong to the engine, not to the source. `TableRevisions` carries four counters — `data`, `view`, `schema`, `policy` — that a subscriber wakes on through `engine.subscribe(axes, listener)`, and `revisionToken(revisions)` folds them into one comparable string. `TableSource` has no counter of its own: the table reads a source on every render, so a hand-rolled source signals change the way any React value does. Publish a new `rows` array when the data changes and keep the same one when it does not — derived views compare arrays by identity. To back a custom source with an engine, build it with `createTableEngine` from `@adapttable/core` — see [the engine, and why it has no React in it](./concepts.md#the-engine-and-why-it-has-no-react-in-it). ## Notes - `groupBy` alone never groups a one-page source. Grouping needs `allFilteredRows` (client) or `groups` (server); without either, the table ignores it and the status bar carries the reason. - In infinite mode, `rows` is every row loaded so far and `fetchNextPage` appends to it — the adapters call it when the bottom of the list scrolls into view and from the Load more button. - `refetch` may return a promise or nothing. The table calls it from the error state's Retry and does not await it. - The table never owns the data. Edits, adds, deletes and reorders reach your app through feature callbacks; a source reports the result through `rows`. - `TableSource`, `TableStateMutators`, `TableSourceCapabilities`, `sourceCapabilities` and `capabilityReason` are exported from `@adapttable/core`; the [API reference](./api.md#source-capabilities) lists their types. --- # Build a React table adapter for any UI kit An adapter is a kit's `DataTable` plus one entry per feature, each drawn with that kit's own components. Every built-in adapter is built from `@adapttable/react/adapter` — the same public entry a new one uses, with the same semver promise as the main entry. The shell behind it resolves the data tier, runs filters, sorting, paging, selection and keyboard wiring, and hands back state; the adapter renders it. **Related:** [Concepts](./concepts.md) · [Feature composition](./features.md) · [Customization](./customization.md) · [The adapter contract](./api.md#the-adapter-contract) ## Two ways to reach a kit | Your kit | Build | Reference | | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | A class-based design system (utility classes, tokens, no component library) | A class map over `@adapttable/unstyled`, which renders native elements | [`@adapttable/shadcn`](../packages/react/adapter-shadcn/src/DataTable.tsx) | | A component library (buttons, inputs, menus, drawers as components) | A full adapter on `useDataTableShell`, every control drawn with a kit component | [`@adapttable/unstyled`](../packages/react/adapter-unstyled/src/DataTable.tsx), and each themed kit | The shadcn adapter is the whole of the first pattern: its `DataTable` passes `shadcnClassNames` to the unstyled `DataTable`, merging a caller's `classNames` over the preset per part, and each feature subpath re-exports the unstyled one (`export * from "@adapttable/unstyled/density"`). The rest of this page covers the second pattern. ## Example — the root table `@acme/ui` stands for your kit. `DesktopTable`, `MobileCards` and `Pager` are the adapter's own files (see [the worked reference](#the-worked-reference--adapttableunstyled)). ```ts // src/types.ts import type { TableSource } from "@adapttable/core"; import type { BaseDataTableProps, UrlStateAdapter } from "@adapttable/react"; import type { DataModeProps } from "@adapttable/react/adapter"; import type { ReactNode } from "react"; /** The five wrapper hooks every themed kit honours. */ export interface DataTableClassNames { root?: string; toolbar?: string; table?: string; card?: string; footer?: string; } export interface DataTableSlots { empty?: ReactNode; noResults?: ReactNode; skeleton?: ReactNode; } export interface DataTablePropsBase extends Omit< BaseDataTableProps, "source" > { source?: TableSource; data?: readonly TRow[]; total?: number; loading?: boolean; error?: Error | null; urlAdapter?: UrlStateAdapter; urlSync?: boolean; urlKey?: string; classNames?: DataTableClassNames; slots?: DataTableSlots; } /** `mode="server"` requires `onQueryChange` at compile time. */ export type DataTableProps = DataTablePropsBase & DataModeProps; ``` ```tsx // src/DataTable.tsx import { Alert, Button, Spinner, TextInput } from "@acme/ui"; import { DataTableShellView, FeatureHostProvider, FeatureProviders, FeatureSlot, STATUS_BAR, TableStatusAnnouncer, TOOLBAR_EXTRAS, useDataTableShell, useTableFeatures, } from "@adapttable/react/adapter"; import type { ReactNode } from "react"; import { DesktopTable } from "./components/DesktopTable"; import { MobileCards } from "./components/MobileCards"; import { Pager } from "./components/Pager"; import type { DataTableProps } from "./types"; function noAutoForm(): ReactNode { return null; } function DataTableContent(incoming: Readonly>) { const props = useTableFeatures(incoming); const shell = useDataTableShell(props, noAutoForm); return ( {(view) => { const { chrome, labels, source, table } = view; const body = { skeleton: props.slots?.skeleton ?? , empty: (chrome.emptyVariant === "noResults" ? props.slots?.noResults : undefined) ?? props.slots?.empty ?? (

{chrome.emptyVariant === "noResults" ? labels.noResults : labels.noData}

), desktop: , mobile: , }[chrome.body]; return (
{props.searchable !== false && ( )}
{chrome.errorState ? ( {chrome.errorState.error.message} {chrome.errorState.retry && ( )} ) : ( body )} {chrome.showFooter && ( )}
); }}
); } /** Mount the providers the composed features contribute, then the table. */ export function DataTable(incoming: Readonly>) { const props = useTableFeatures(incoming); return ( {...props} /> ); } ``` The skeleton leaves out the filters, saved views, column menu, bulk bar and the other root slots; [where the table places slots](#where-the-table-places-slots) lists each one and where the unstyled adapter puts it. ## Example — a feature entry A feature that draws a control keeps the headless factory's behaviour and adds the kit's component to a slot. This is the whole of a kit's `/fullscreen` entry (`@acme/adapttable/fullscreen` in the package below): ```tsx // src/fullscreen.tsx import { IconButton } from "@acme/ui"; import { extendFeature, slotRender, type StaticTableFeature, TOOLBAR_EXTRAS, type ToolbarExtrasSlotProps, } from "@adapttable/react/adapter"; import { fullscreen as core } from "@adapttable/react/features"; function FullscreenButton(props: Readonly) { const { onToggleFullscreen, isFullscreen, labels } = props; if (!onToggleFullscreen) return null; return ( {isFullscreen === true ? "✕" : "⛶"} ); } /** Take the table fullscreen, with this kit's own toolbar toggle. */ export function fullscreen(): StaticTableFeature { return extendFeature(core(), [ slotRender(TOOLBAR_EXTRAS, (props) => ), ]); } ``` ## Example — a Chrome and its required slot A `*Chrome` component owns structure, labels and wiring; the visible control is a slot the adapter must fill. `ColumnGroupToggleChrome` takes one: ```tsx // src/column-groups.tsx import { IconButton } from "@acme/ui"; import { COLUMN_GROUP_TOGGLE, type ColumnGroupToggleButtonProps, ColumnGroupToggleChrome, type ColumnGroupToggleProps, extendFeature, slotRender, type StaticTableFeature, } from "@adapttable/react/adapter"; import { collapsibleColumnGroups as core } from "@adapttable/react/features"; /** The one visible control the chrome needs — drawn with this kit. */ function ToggleButton({ label, expanded, className, onClick, }: Readonly) { return ( {expanded ? "▼" : "▶"} ); } export function ColumnGroupToggle(props: Readonly) { return ( ); } /** Collapse a header group, with this kit's chevron. */ export function collapsibleColumnGroups(): StaticTableFeature { return extendFeature(core(), [ slotRender(COLUMN_GROUP_TOGGLE, (props) => ( )), ]); } ``` The unstyled adapter fills the same slot with a `