# 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://orwa-mahmoud.github.io/adapttable/llms.txt.
---
# Get started with AdaptTable — React table for your UI kit
▶ **Nothing to install yet — [open the live demo](https://orwa-mahmoud.github.io/adapttable/demo/) and use it.** Flip
between [Mantine](https://orwa-mahmoud.github.io/adapttable/demo/?kit=mantine) · [MUI](https://orwa-mahmoud.github.io/adapttable/demo/?kit=mui) ·
[Chakra](https://orwa-mahmoud.github.io/adapttable/demo/?kit=chakra) · [Ant Design](https://orwa-mahmoud.github.io/adapttable/demo/?kit=antd) ·
[Radix](https://orwa-mahmoud.github.io/adapttable/demo/?kit=radix) · [Base UI](https://orwa-mahmoud.github.io/adapttable/demo/?kit=base-ui) ·
[shadcn](https://orwa-mahmoud.github.io/adapttable/demo/?kit=shadcn) · [Tailwind](https://orwa-mahmoud.github.io/adapttable/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, filterable, paginated
table with URL-synced state, selection + bulk actions, RTL, and dark mode.
## 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 69–79 kB min+gzip (measured 2026-09-15 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
```
## 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 \|\| ^7 \|\| ^8 \|\| ^9` |
| Chakra UI | `^3.13.0` |
| Ant Design | `^6` |
| Radix Themes | `^3` |
| Base UI | `^1` |
| 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. 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
- [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
keyboard, dataset-relative indices.
- [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://orwa-mahmoud.github.io/adapttable/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://orwa-mahmoud.github.io/adapttable/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. Both built-in source hooks fulfil it, and the table renders without
knowing which produced it.
```ts
interface TableSource {
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
refetch?: () => void; // re-runs the underlying fetch
error: Error | null;
paginationMode: "infinite" | "paged";
// state
page: number;
limit: number;
search: string;
sortBy: string | undefined;
sortDir: "asc" | "desc" | undefined;
extra: Record;
// setters
setPage;
setLimit;
setSort;
setSearch;
setExtra;
setExtras;
clearAll;
}
```
Because the table is agnostic to the source's origin, you can switch between
in-memory and server data — or build a custom source — 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
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**, using the same breakpoint the table uses, so the two
never drift. 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/core` — attach
the returned ref to a sentinel element after your last row to get the same
behaviour in custom markup:
```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 `virtualize`. The
core exports `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 prop 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
row.id}
paginationMode="infinite"
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 the shared
mobile breakpoint. `hideOnMobile` can hide low-value columns, while
`mobileIdentityColumns` preserves a configurable number of leading
desktop-visible columns so every card keeps enough identity to be useful.
```tsx
```
## 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, 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. |
`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.
## 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.
---
# React table features — optional imports, presets and plugins
▶ **See it working:** [the Feature Lab](https://orwa-mahmoud.github.io/adapttable/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 { 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.
The props that used to arm features are inert: `enableColumnMenu`,
`bulkActions`, `contextMenu`, `commandPalette`, `statusBar`, `sidePanel`,
`findInTable` and the rest configure a feature you compose, and draw nothing on
their own.
## Kit subpaths
Every public adapter exports the same factories:
| Import | Factory |
| ----------------------------------- | ----------------------------------------------------------------- |
| `@adapttable//row-reorder` | `rowReorder` |
| `@adapttable//saved-views` | `savedViews` |
| `@adapttable//grouping-panel` | `groupingPanel` — grouping headers + interactive panel |
| `@adapttable//grouping` | `grouping` — code-fixed grouping without the interactive panel |
| `@adapttable//editing` | `editing` |
| `@adapttable//virtualize` | `virtualize` |
| `@adapttable//column-menu` | `columnMenu` |
| `@adapttable//cell-navigation` | `cellNavigation` |
| `@adapttable//preset` | `standardFeatures`, `StandardFeatureOptions` |
| `@adapttable//features` | every factory, plus `applyTableFeatures` |
| `@adapttable//pivot` | `PivotPanel` and the pivot engine (`pivot`, `pivotTableModel`, …) |
`` is `mantine`, `mui`, `chakra`, `antd`, `radix`, `base-ui`, `shadcn`,
or `unstyled`. The factories themselves live in `@adapttable/react/features`;
the kit subpaths re-export them so the import path matches the table.
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,
fit-columns, the fullscreen toggle, header filters, multi-sort, resizable
columns and the status bar.
Configurable preset members join only when you give them input. 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: { storage: "local" },
}),
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 69 kB gzipped and the same table with
`standardFeatures()` composed is 122 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: "number",
ops: ["eq", "gt", "lt"],
defaultOp: "eq",
stateKeys: (def) => [def.key],
match: () => true,
chips: () => ({}),
conditionToExtra: () => ({}),
});
return () => {
/* table unmounted, or `features` changed */
};
},
};
;
```
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,
} from "@adapttable/react/adapter";
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` · `feature` (ad-hoc patch) · `applyTableFeatures`
(the merge used by every adapter) · `useTableFeatures` (apply + `setup(host)`,
the hook every adapter runs) · `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
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 |
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.
```tsx
import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
// or import from "@adapttable/mui", "@adapttable/chakra", "@adapttable/antd",
// "@adapttable/radix", "@adapttable/shadcn", "@adapttable/unstyled" — same props everywhere.
import {
DataTable,
type PaginatedResponse,
type TableQueryParams,
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.hasNext ? 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 and
StrictMode double-mounts 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 |
**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 { tableQueryKey, tableQueryBaseKey } from "@adapttable/core";
const infinite = useInfiniteQuery({
queryKey: tableQueryKey(query, { scope: "people" }),
queryFn: ({ signal }) => fetchPeople(query, signal),
getNextPageParam: (last) => last.nextCursor,
});
// 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, each
covered by a test:
- **One request per query.** Queries are compared by value, so setting the same
search term three times in a tick, an identical re-render, or a StrictMode
double-mount all collapse into a single call.
- **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`; requires `supports.aggregates`. 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. |
## 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://orwa-mahmoud.github.io/adapttable/demo/).
---
# React table columns — ColumnDef, accessors, sorting, pinning & custom cells
▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — this page's feature is the starter's whole `columns` array in `src/App.tsx`; edit it in the browser, no install. [Other UI kits →](./getting-started.md#try-it-in-stackblitz)
Columns are plain objects — declare a `key` and the table renders the value, derives the header, and wires sorting and filtering around it. Everything beyond the key is an opt-in refinement.
## Example
```tsx
import { type CellProps, type ColumnDef, DataTable } from "@adapttable/mantine"; // or mui, chakra, antd, radix, shadcn, unstyled
import { Badge } from "@mantine/core";
interface Person {
id: string;
name: string;
nameAr: string;
department: { name: string };
salary: number;
status: "active" | "on-leave";
hiredAt: string;
}
const people: Person[] = [
{
id: "1",
name: "Amira Hassan",
nameAr: "أميرة حسن",
department: { name: "Engineering" },
salary: 96000,
status: "active",
hiredAt: "2021-03-15",
},
{
id: "2",
name: "Tom Becker",
nameAr: "توم بيكر",
department: { name: "Design" },
salary: 78000,
status: "on-leave",
hiredAt: "2022-11-01",
},
{
id: "3",
name: "Lina Park",
nameAr: "لينا بارك",
department: { name: "Engineering" },
salary: 105000,
status: "active",
hiredAt: "2019-07-20",
},
];
// Define Cell components at module level so their identity is stable.
function StatusCell({ row }: CellProps) {
return (
{row.status}
);
}
const columns: ColumnDef[] = [
// Bare key: auto header "Name"; i18n swaps the data path when locale="ar".
{ key: "name", i18n: { ar: "nameAr" }, sortable: true },
// Dot path reaches nested values; auto header "Department Name".
{ key: "department.name", header: "Department" },
// accessor formats; sortValue keeps the column sortable by the raw number.
{
key: "salary",
header: "Salary (USD)",
accessor: (r) => r.salary.toLocaleString(),
sortValue: (r) => r.salary,
sortable: true,
align: "end",
width: 140,
},
// Cell: a full React component receiving { row, rowIndex }.
{ key: "status", Cell: StatusCell, mobileLabel: "Status" },
{ key: "hiredAt", hideOnMobile: true, meta: { exportFormat: "date" } },
];
export function People() {
return (
r.id}
locale="en"
/>
);
}
```
## How it works
- A bare `{ key }` is a complete column: the key doubles as the row's data path (dot paths reach nested values, `"department.name"`), and the header is auto-humanised (`hiredAt` → "Hired At"). An explicit `header` always wins, in any language.
- `renderHeader` replaces the caption only. The cell still owns sort, resize and the menu, and passes a `controller` (`label`, `sortDir`, `toggleSort`) so a custom caption can stay wired. `headerTooltip` is a native title; `headerActions` sit after the caption. `renderFooter` replaces one summary cell; `tableFooter` is a free slot under the table.
- Cell content resolves `Cell` → `accessor` → the key's data path. `Cell` is a React component receiving `{ row, rowIndex }`; `accessor` is the lighter function form. Mini charts are a separate import — see [sparkline columns](./sparkline.md).
- `sortable` opts a column into sorting; on frontend data the comparator reads `sortValue`, falling back to the column's accessor. See [sorting](./sorting.md).
- `i18n` maps locale tags to alternative data paths; the table's `locale` prop picks one (exact tag → primary subtag → `key`). The cell, client-side sort, and the column's filter all follow the resolved path — header text does not.
- `renameable` opts a leaf into user naming when the table also provides
`onColumnRename`. This changes display text, mobile and export labels while
the key and localized data paths stay fixed. See
[column management](./column-management.md).
- `hideOnMobile` / `hideOnDesktop` drop a column per layout; `mobileLabel` overrides the label on mobile cards.
- `key` is also the value sent to a backend as `sortBy`, so keep it API-stable.
## Options
| Prop | Type | Default | Description |
| ----------------- | ------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `key` | `string` | required | Unique id; data path for the cell value; the backend `sortBy` value. |
| `header` | `ReactNode` | humanised from `key` | Header content, pre-translated by the caller. |
| `renderHeader` | `(ctx) => ReactNode` | — | Custom caption; receives a controller so sort/resize stay available. |
| `renderFooter` | `(ctx) => ReactNode` | — | Custom summary-row cell. |
| `headerTooltip` | `string` | — | Native tooltip on the caption. |
| `headerActions` | `ReactNode` | — | Host controls after the caption. |
| `renameable` | `boolean` | `false` | Allow a host-persisted user display name from the Columns menu. |
| `accessor` | `(row) => ReactNode` | read the key's data path | Lightweight cell renderer. |
| `Cell` | `ComponentType>` | — | Component per row, receives `{ row, rowIndex }`; wins over `accessor`. |
| `sortable` | `boolean` | `false` | Enable sorting for this column. |
| `sortValue` | `(row) => SortableValue` | the generated accessor value | Primitive extractor for the client-side sort. See [sorting](./sorting.md). |
| `exportValue` | `(row) => unknown` | the display value | Value written to a CSV export when the file should carry something other than the formatted cell. |
| `align` | `"start" \| "center" \| "end"` | `"start"` | Text alignment within the cell. |
| `width` | `number \| string` | — | Width passed through to the rendered header/cell. |
| `mobileLabel` | `string` | `header` (when a string) | Label on mobile card layouts. |
| `hideOnMobile` | `boolean` | `false` | Hide the column entirely on mobile. |
| `hideOnDesktop` | `boolean` | `false` | Hide the column entirely on desktop. |
| `aggregatable` | `false \| true \| { default?, operations }` | omitted | Whether a reader may aggregate this column, and with which operations. See [row grouping](./row-grouping.md). |
| `formatAggregate` | `(value, context) => DisplayValue` | — | How a group aggregate of this column reads. Presentation only. |
| `group` | `string` | — | Spanning header above adjacent columns sharing the name. See below. |
| `i18n` | `Record` | — | Per-locale data paths for the column's value. |
| `meta` | `Record` | — | Free-form bag your own code can read back. |
| `locale` | `string` (table prop) | — | Active locale tag (`"ar"`, `"ar-EG"`); drives `i18n` path resolution. |
## The cell as text
`accessor` returns a `ReactNode`, which is right for rendering and useless
everywhere else: a status badge is a React element, an avatar cell is a
component, and neither is a word. Anything that needs the cell as a **string** —
a screen-reader announcement, an `aria-label`, a tooltip, a clipboard copy —
has nothing to read.
`formatValue` is that string:
```tsx
{
key: "status",
accessor: (row) => {row.status},
formatValue: (row) => row.status, // what a screen reader says
}
```
Text is always available, so `formatValue` only matters where it makes the text
_accurate_. `columnText(column, row)` resolves it in this order:
1. `formatValue` — the column stating its own text
2. `exportValue` — the underlying value, minus the formatting
3. `sortValue` — a primitive by definition
4. `accessor`, when it happens to return a primitive
5. the key's data path
with one deliberate restriction on that last step: **a column that renders its
own cell never falls back to the data path.** A column with
`accessor: () => null` renders an empty cell, and reading its path would
announce a value the user cannot see — worse than announcing nothing. Such a
column resolves to `""`, and giving it a `formatValue` is the fix.
## Computed columns
A total, a margin, a full name, days-until-due — columns whose value is derived
rather than stored. Writing the derivation into `accessor` works until the
column has to do anything else: sorting then compares the formatted string, so
`"$1,240.00"` sorts before `"$90.00"`; filtering has nothing to match; the
export carries the formatting; and the function runs again for every cell on
every render.
`computed` declares the derivation once and wires all four surfaces from it:
```tsx
import { computed } from "@adapttable/core";
const columns = [
{ key: "quantity" },
{ key: "unitPrice" },
computed({
key: "total",
header: "Total",
deps: (row) => [row.quantity, row.unitPrice],
value: (row) => row.quantity * row.unitPrice,
format: (total) => money.format(total),
column: { sortable: true, align: "end" },
}),
];
```
The screen shows `$1,240.00`; sorting, filtering and export all see `1240`.
- **`deps` is required, and listing them is the whole contract.** The value is
recomputed when any dependency changes and reused when none do. A field the
derivation reads but does not declare becomes a stale cell the moment the
data changes underneath it.
- **The result is cached per row**, in a `WeakMap` keyed by the row object — a
row that leaves the page takes its cached value with it, so a long-lived
table cannot grow a cache it never releases.
- **`format` is display only.** Leave it out and primitives and dates render as text; any other value renders empty, since an object has no useful reading in a cell.
- **`column` carries everything else** a column can be — `sortable`, `align`,
`width`, `filter`, `hideOnMobile`. `accessor`, `sortValue` and `exportValue`
are derived and cannot be set here, which is what keeps the four surfaces
from disagreeing.
**Define the columns at module level, or memoise them.** The cache lives
inside the column `computed` returns, so rebuilding the column on every
render throws the cache away with it — values stay correct, nothing is
reused. It is the same rule `Cell` already asks for.
Rows must be objects, since the cache is keyed by row identity. The spec type is exported as `ComputedColumnSpec` for callers that build columns dynamically.
## Grouped headers
Give adjacent columns the same `group` and they render under one spanning
header cell. A string is one level; a path stacks one header row per depth.
That shortcut is presentational — for collapse options and groups that stay
together through reorder, use a `ColumnGroupDef` parent with `children`.
See [column groups](./column-groups.md).
```tsx
const columns: ColumnDef[] = [
{ key: "firstName", header: "First", group: "Name" },
{ key: "lastName", header: "Last", group: "Name" },
{ key: "q1", header: "Q1", group: ["Finance", "2026"] },
{ key: "q2", header: "Q2", group: ["Finance", "2026"] },
{ key: "hiredAt", header: "Hired" },
];
```
```text
| Name | Finance | |
| | 2026 | |
| First | Last | Q1 | Q2 | Hired |
```
Columns without a `group` sit under a blank spanning cell, so the header rows
always line up. The grouping is **presentational and adjacency-based**: the
span is computed from the columns as they are currently ordered, so dragging a
column out of the middle of a group splits it into two spans rather than
pretending the layout is something it is not. Reorder them back together and
the group closes up again.
Pass `collapsibleColumnGroups` and each real group header gains a toggle. What
a collapsed group shows is that group's own options — an arrow stub by
default, a kept child via `collapsedKey`, or a cell via `collapsedRender`.
See [column groups](./column-groups.md). Collapse state lives on
`columnLayout.collapsedGroups` and the URL (`colGroupCollapse`); group ids are
`path.join("\u001f")` so a label may contain `/`. On mobile the card layout has
no header row, but the same visible-column filter applies — cards hide the same
leaves a collapsed group hid on desktop.
## Notes
- Define `Cell` components at module level (or memoise them) — an inline component re-mounts every render and defeats row memoisation.
- Path-derived cells render primitives only; a non-primitive value at the path renders nothing. Use `accessor` or `Cell` for objects.
- A column whose `accessor` returns JSX needs `sortValue` to be sortable — without it the sort silently no-ops and a dev warning fires.
- `mobileLabel` only falls back to `header` when the header is a string; with a JSX header, set `mobileLabel` explicitly (it also names the column in the Columns menu).
- Duplicate column keys trigger a development warning — keys must be unique within the table.
See it live in the [demo](https://orwa-mahmoud.github.io/adapttable/demo/).
---
# React table column groups — spanning headers, collapsible
▶ **See it working:** [collapse column groups in Mantine](https://orwa-mahmoud.github.io/adapttable/demo/mantine/column-groups/) — one table, three groups, open by default: Contact folds to a chevron, Assignment keeps Team, Delivery shows a money-for-days brief (`align: "start"`). Actions stays ungrouped at the end. The same page exists for MUI, Chakra, antd, Radix, Base UI, shadcn and Tailwind.
A parent with `children` is a **column group**: one spanning header over its
leaves. Collapse is per group — each parent decides what remains. Omit
`collapsibleColumnGroups` and the headers stay static.
This is not [row grouping](./row-grouping.md). Rows fold under a `groupBy`;
columns span under a parent header.
**Related:** [Columns](./columns.md) · [Column management](./column-management.md)
## Tree columns
`columns` accepts a mix of leaf `ColumnDef`s and `ColumnGroupDef`
parents (`ColumnInput`). Tree groups default `marryChildren: true`, so a
reorder cannot split them.
```tsx
import { DataTable, type ColumnInput } from "@adapttable/mantine";
const columns: ColumnInput[] = [
{
header: "Contact",
children: [
{ key: "name", header: "Name" },
{ key: "role", header: "Role" },
],
},
{
header: "Assignment",
collapsedKey: "team",
children: [
{ key: "team", header: "Team" },
{ key: "status", header: "Status" },
],
},
{
header: "Delivery",
align: "start",
collapsedRender: (row) => `${row.budget} for 35 days`,
children: [
{ key: "timeline", header: "Timeline" },
{ key: "budget", header: "Budget" },
],
},
];
row.id}
collapsibleColumnGroups
/>;
```
HTML-table kits (Mantine, MUI, Chakra, Radix, Base UI, unstyled) render the
same tree with `rowSpan` on ungrouped leaves, so Actions sits beside Delivery
and its children instead of under a blank group row. Ant uses native grouped
columns; `htmlGroupedHeaderPlan` is that tree flattened for `
`. HTML kits
draw the line under an open group with `groupedHeaderChildRule` so adjacent
groups do not share one stroke.
## Collapse
Pass `collapsibleColumnGroups` and each real group header gains a toggle. What
a collapsed group shows is that group's own options — there is no table-wide
mode.
| Result | How |
| --------------------------------------------- | ----------------------------------------------------------------------- |
| Thin **arrow stub** (chevron + hairline cell) | No `collapsedKey`, no `collapsedRender`, no child `groupShow: "closed"` |
| Keep one child | `collapsedKey: "timeline"` (or that leaf `groupShow: "closed"`) |
| Custom one cell | `collapsedRender: (row) => …` (wins over `collapsedKey`) |
| Always visible child | `groupShow: "always"` |
| Open-only child | `groupShow: "open"` (default under a collapsible group) |
A collapsed `collapsedRender` or stub group drops the child header row — the
title fills the header height, the same way an ungrouped leaf does.
`collapsedKey` keeps both rows: the group title and the stayed child's
header. Each group's rule under the title covers only that group's children;
adjacent groups do not share one stroke.
The stub hides the visible caption (`hideLabel`). The toggle's `aria-label`
still names the group (`Expand column group: Delivery`). The stub column is
locked to a chevron width so leftover table space cannot stretch it into a
blank data column.
`collapsedRender` wins over `collapsedKey`. State lives on
`columnLayout.collapsedGroups` and the URL (`colGroupCollapse`); group ids are
`path.join("\u001f")` (`columnGroupId`) so a label may contain `/`.
## The flat `group` shortcut
Adjacent leaves with the same `group` still span under one header. A string is
one level; a path stacks rows. That shortcut is **presentational**: a reorder
that breaks adjacency splits the span, and collapse (when armed) is an arrow
stub unless a child sets `groupShow`. Prefer a tree parent when the group has
collapse options or must stay married.
```tsx
const columns: ColumnDef[] = [
{ key: "firstName", header: "First", group: "Name" },
{ key: "lastName", header: "Last", group: "Name" },
{ key: "hiredAt", header: "Hired" },
];
```
## Mobile
The card layout has no header row. The same visible-column filter applies —
cards hide the leaves a collapsed group hid on desktop. The arrow stub itself
is `hideOnMobile`, so a collapsed Delivery does not become an empty card field.
## Helpers
`flattenColumnTree` turns a mixed `ColumnInput[]` into leaves plus a
`ColumnGroupRecord` map. `applyCollapsedColumnGroups` hides leaves under
collapsed ids and inserts a stub (`COLUMN_GROUP_STUB_PREFIX`,
`isColumnGroupStubKey`) or a `collapsedRender` column
(`COLUMN_GROUP_RENDER_PREFIX`, `isColumnGroupRenderKey`).
`marriedOrderHolds` rejects a reorder that would split a married tree group.
`columnGroupHeaderCaption` returns the visible caption, or `null` on a stub.
`htmlGroupedHeaderPlan` is the HTML-table rowspan plan (ungrouped leaves
beside the group band; a collapsed brief fills the header).
`groupedHeaderChildRule` is the inset hairline under an open group title.
`groupedHeaderCellStyle` applies that hairline and `columnGroupStubStyle`
(the 36px lock so a stub cannot stretch). `groupedHeaderLabelStyle` keeps
the collapse chevron on the same line as the group title so a one-child
group cannot wrap the arrow above the caption.
`isColumnGroup` narrows a `ColumnInput` to a parent.
See it live in the [demo](https://orwa-mahmoud.github.io/adapttable/demo/mantine/column-groups/).
---
# React table sparkline columns
▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — import `@adapttable/react/sparkline`. [Other UI kits →](./getting-started.md#try-it-in-stackblitz)
▶ **See it working:** [turn on the sparkline column in the Feature Lab](https://orwa-mahmoud.github.io/adapttable/demo/all-options/) — switch kits with it on and the chart is drawn by each one.
A sparkline is a mini chart in a cell: bar, line or area, drawn as inline
SVG. It ships as `@adapttable/react/sparkline` so a table that never
imports it never pays for it. No chart library.
```tsx
import { sparklineColumn } from "@adapttable/react/sparkline";
import { DataTable } from "@adapttable/mantine";
const columns = [
sparklineColumn({
key: "load",
header: "Load",
values: (row) => row.history,
kind: "area",
}),
];
row.id} />;
```
`Sparkline` is the chart on its own, for a host `Cell` or `accessor`.
`sparklineColumn` wires the usual surfaces: the cell draws the SVG,
`sortValue` is the last finite number, and `exportValue` is the series
as `"1, 2, 3"` so CSV and xlsx get the numbers, not markup.
The SVG is a fixed size (80×28 by default). No `ResizeObserver`, so a
virtualized row can mount and unmount it without measuring. Mobile
cards render the same cell. Time stays left-to-right even under RTL —
mirroring a series would put "last" on the left. Pass a `label` for a
translated summary; the default is a numeric sentence (`3 values, min
1, max 4, last 2`).
Omit the import and nothing is drawn and nothing is downloaded.
---
# React table sorting — multi-column, server-side & URL-synced
▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — this page's feature is already wired in `src/App.tsx` (`sortable` columns); edit it in the browser, no install. [Other UI kits →](./getting-started.md#try-it-in-stackblitz)
Mark a column `sortable` and header clicks cycle it ascending → descending → cleared, with the state kept in the URL so reloads and shared links restore the exact order. Multi-column sorting is one extra prop.
## Example
```tsx
import {
type ColumnDef,
DataTable,
useFrontendData,
} from "@adapttable/mantine"; // or mui, chakra, antd, radix, shadcn, unstyled
interface Person {
id: string;
name: string;
team: string;
hiredAt: string;
rating: number | null;
}
const people: Person[] = [
{
id: "1",
name: "Amira Hassan",
team: "Platform",
hiredAt: "2021-03-15",
rating: 4.6,
},
{
id: "2",
name: "Tom Becker",
team: "Design",
hiredAt: "2022-11-01",
rating: null,
},
{
id: "3",
name: "Lina Park",
team: "Platform",
hiredAt: "2019-07-20",
rating: 4.9,
},
{
id: "4",
name: "Sam Ortiz",
team: "Design",
hiredAt: "2023-02-08",
rating: 4.1,
},
];
const columns: ColumnDef[] = [
{ key: "name", sortable: true },
{ key: "team", sortable: true },
// Formatted cell + sortValue: sort by the real date, not the display string.
{
key: "hiredAt",
accessor: (r) => new Date(r.hiredAt).toLocaleDateString(),
sortValue: (r) => r.hiredAt,
sortable: true,
},
// null ratings always sort last, ascending or descending.
{ key: "rating", sortable: true },
];
export function People() {
// `defaults` sets the initial sort; the URL overrides it once the user sorts.
const source = useFrontendData({
data: people,
columns,
defaults: { sortBy: "name", sortDir: "asc" },
});
return (
r.id}
multiSort // shift-click chains a second column
/>
);
}
```
## How it works
- A header click cycles the column inactive → ascending → descending → cleared. A click on a different column starts it ascending.
- Frontend tier (`data` / `useFrontendData`): rows are compared by `sortValue`, falling back to the column's accessor. Numbers compare numerically, everything else by locale-aware string comparison; the sort is stable.
- `null` / `undefined` / `NaN` always sort last — in both directions. A descending sort never flips the blanks to the top.
- Sort state lives in the URL: `sortBy` + `sortDir` for a single sort, and the chain as `sort=name:asc,hiredAt:desc` under `multiSort`. `defaults` apply only while the URL is silent; clearing a defaulted sort writes an empty `sortBy=` marker so it does not resurrect.
- `multiSort` adds shift-click (or shift-Enter): each shift-click adds the column to the chain or advances it (asc → desc → removed). Chained headers expose a 1-based `data-sort-index` for the order badge. A plain click resets the chain back to a single sort.
- Server tier (`onQueryChange` / `useQuerySource`): the table only emits the state — `query.sortBy`, `query.sortDir`, and `query.sortLevels` for a chain. Your backend does the comparing; `sortValue` is unused.
## Options
| Prop | Type | Default | Description |
| --------------- | ------------------------------------------ | ---------------------------- | ---------------------------------------------------------------------------------- |
| `sortable` | `boolean` (per `ColumnDef`) | `false` | Enable sorting for the column. |
| `sortValue` | `(row) => SortableValue` (per `ColumnDef`) | the generated accessor value | Primitive extractor for the client-side comparator. Unused for server-sorted data. |
| `multiSort` | `boolean` | `false` | Opt into multi-column sorting via shift-click / shift-Enter. |
| `defaults` | `Partial & { extra? }` | — | Initial sort (`sortBy`, `sortDir`) on the source builders; URL values win. |
| `sortByOptions` | `SortByOption[]` | — | Options for the mobile sort-by select. |
## Notes
- `defaults` works both as a `` prop and as an option on the source builders — `defaults={{ sortBy: "name" }}` default-sorts the zero-ceremony `data` tier directly (ascending unless `sortDir` is given; explicit URL state wins).
- A column whose accessor returns JSX needs `sortValue`; otherwise the sort cannot resolve a value and a development warning fires (`sortBy` matching no column warns too).
- The plain-click reset of a multi-sort chain is deliberate: without it the chain would keep superseding the single sort and the click would appear dead.
- A hand-edited URL sort with no `sortDir` falls back to ascending.
- In multi-sort, ties at level N fall through to level N+1; rows that tie on every level keep their original order.
See it live in the [demo](https://orwa-mahmoud.github.io/adapttable/demo/).
---
# React table filtering — multi-condition, chips, operators & URL-synced
▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — this page's feature is already wired in `src/App.tsx` (declarative `filter` widgets on four columns); edit it in the browser, no install. [Other UI kits →](./getting-started.md#try-it-in-stackblitz)
Declare a filter once and AdaptTable derives everything from it: the
kit-native widget, the `f_` URL param, the removable chip, and (on
frontend data) the row predicate — no wiring. Nested AND/OR groups are
the [advanced filter tree](./filter-tree.md).
## Example
```tsx
// Needs your kit's provider once at the root (e.g. ).
import { DataTable } from "@adapttable/mantine"; // or mui, chakra, antd, radix, shadcn, unstyled
import { filters } from "@adapttable/mantine/filters";
interface Person {
id: string;
name: string;
department: { name: string };
status: string;
salary: number;
hiredAt: string; // ISO date
}
const data: Person[] = [
{
id: "1",
name: "Amira Haddad",
department: { name: "Engineering" },
status: "active",
salary: 98000,
hiredAt: "2021-03-15",
},
{
id: "2",
name: "Jonas Weber",
department: { name: "Design" },
status: "onleave",
salary: 76000,
hiredAt: "2019-11-02",
},
{
id: "3",
name: "Priya Nair",
department: { name: "Engineering" },
status: "active",
salary: 112000,
hiredAt: "2023-06-20",
},
{
id: "4",
name: "Sam Ortiz",
department: { name: "Sales" },
status: "left",
salary: 64000,
hiredAt: "2018-01-09",
},
{
id: "5",
name: "Lena Park",
department: { name: "Design" },
status: "active",
salary: 89000,
hiredAt: "2022-09-12",
},
];
export function PeopleTable() {
return (
r.id}
columns={[
{ key: "name", filter: "text", sortable: true },
// "auto" derives the choices from the data (frontend tier).
{
key: "department.name",
header: "Department",
filter: { type: "select", options: "auto" },
},
// Async options — usually `async () => (await fetch("/api/statuses")).json()`.
{
key: "status",
filter: {
type: "multiSelect",
options: async () => [
{ value: "active", label: "Active" },
{ value: "onleave", label: "On leave" },
{ value: "left", label: "Left" },
],
},
},
{ key: "salary", filter: "numberRange", sortable: true },
{ key: "hiredAt", filter: "dateRange" },
]}
features={[
filters([
{
key: "tenure",
type: "numberRange",
label: "Tenure (years)",
getValue: (r) =>
(Date.now() - new Date(r.hiredAt).getTime()) / 31_557_600_000,
},
]),
]}
filtersMode="popover" // the default; "drawer" or "header" — one mode, never stacked
/>
);
}
```
Import `filters` from `@adapttable//filters` — the import is the switch.
Column `filter` declarations still need that feature: pass `filters([])` when
every filter lives on a column, or pass standalone defs as above. See
[feature composition](./features.md).
## How it works
- Two declaration sites, merged column-first: the column `filter` shorthand
(a bare type like `"dateRange"`, or a definition without `key`/`label` —
both inherited from the column) and standalone entries passed to
`filters([…])` for filters with no column. On a key collision the standalone
definition wins and a development warning points at the duplicate.
- Seven built-in types (`FILTER_TYPES`): `text`, `select` (equals),
`multiSelect` (wrapping multi-value chips),
`checklist` (Excel-style distinct values with search, select-all and
counts — from `source.facets` when present, otherwise
`source.allFilteredRows`; a server page that omits both does not offer
the widget), `boolean` (any / true / false — never a checkbox),
`dateRange`, `numberRange`.
- Widgets are operator-first. Text offers equals / not equals / contains /
not contains / starts with / ends with / empty / not empty. Numbers offer
`=` `≠` `>` `≥` `<` `≤` between / in / not in. Dates offer before / after /
on / on-or-after / on-or-before / between / empty. The operator token is
stored as `f_Op` (readable, stable across releases) beside the value
keys (`f_name`, `f_salaryMin`/`f_salaryMax`, `f_hiredAtFrom`/`f_hiredAtTo`).
Links written before `Op` existed still work: text defaults to contains,
and a Min/Max pair still infers at-least / at-most / between.
- `select`/`multiSelect` options come from a static `{ value, label }[]`,
`"auto"` (distinct values derived from the frontend dataset, sorted,
capped at `AUTO_OPTIONS_LIMIT` = 50, the same number as
`FILTER_AI_OPTIONS_LIMIT`), or an async loader — one shared fetch
serves both the form and the chip labels, and active chips re-label from
raw values once it resolves.
- A definition's `key` doubles as the row's dot path for the client-side
predicate (`"department.name"` reaches nested values); `getValue` overrides
it for computed values.
- Active filters render as removable chips with a clear-all that resets every
filter (and the page) while search and sort survive; `onClearFilters`
replaces the built-in handler.
## Options
`FilterDef` (entries of `filters`, and the column `filter` object minus
`key`/`label`):
| Prop | Type | Default | Description |
| ------------- | ----------------------------------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `key` | `string` | — | State key and `f_` URL param. Doubles as the row's dot path unless `getValue` overrides it. |
| `type` | `string` | — | Built-in `FilterType` or a custom type registered on `filterTypes`. |
| `label` | `string` | humanized `key` | Widget and chip label (`hiredAt` → "Hired At"). |
| `options` | `FilterOption[] \| "auto" \| () => Promise` | — | Choices for `select` / `multiSelect`. |
| `getValue` | `(row) => unknown` | reads `key` as a path | Row-value extractor for the client-side predicate. |
| `placeholder` | `string` | — | Placeholder for text-like inputs. |
| `ai` | `false \| FilterAiOptions` | visible, options ≤ 50 | Assistant catalog. `false` hides the filter. `{ options: false }` keeps it and omits values. A number sends values only when the static list is that long or shorter (`FILTER_AI_OPTIONS_LIMIT`). `"auto"` and async loaders are never fetched into the prompt. |
| Factory / prop | Type | Default | Description |
| --------------------------- | ----------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `filters([…])` | `FilterDef[] \| ReactNode` | — | Declarative array → the adapter builds the form; JSX → you draw it (escape hatch). Use `filters([])` when every filter is on a column. |
| `filtersMode` | `"popover" \| "drawer" \| "header"` | `"popover"` | One container. Popover: anchored card, no backdrop. Drawer: panel + backdrop. Header: compact per-column row; hides the Filters button. |
| `onClearFilters` | `() => void` | built-in clear | Clear handler used by the drawer and the chip strip. |
| `filterLabels` | `Record` | derived | Per-key chip label resolvers. Derived automatically by declarative filters; needed only for JSX filters (or to override a derived label). |
| `extraChips` | `ActiveFilterChip[]` | — | Extra chips driven by non-URL state, merged with the derived chips. |
| `activeFilterCount` | `number` | chip count | Overrides the Filters-button badge. |
| `closeHeaderFilterOnSelect` | `boolean` | `false` | Close a header-filter overlay after a finished single-control write (select/boolean, or a valueless operator). Off by default. |
| `filterTypes([…])` | `FilterTypeSpec[]` | built-ins | Extra or replacement filter types merged onto `defaultFilterRegistry`. Same `type` replaces. Compose via `features`. |
| `headerFilters()` | factory | — | Compose when `filtersMode="header"` (or pass `headerFilters` on a test harness). Desktop only. Never stacked with the popover or drawer. |
## Headless filter primitives
The pieces behind the auto-built forms are exported for custom filter UIs:
- **Count filters** (the numeric operator + value pair, committed stable in
[Versioning & stability](./versioning.md)): `COUNT_OPERATORS` is the operator
list and `COUNT_OPERATOR_SYMBOL` the display symbol per `CountOperator`;
`CountFilterState` is the widget state; `countFilterExtra` /
`countFilterStateFromExtra` convert state to and from the filter bag;
`isCountFilterComplete`, `clearCountFilterExtra`,
`sanitizeCountFilterParams` and `countFilterChipLabel` handle validation,
reset, outgoing params, and the chip text.
- **Operators**: `TEXT_OPS` / `NUMBER_OPS` / `DATE_OPS` are the stable URL
tokens (`FilterOp`, `TextOp`, `NumberOp`, `DateOp`). `filterOpKey` /
`FILTER_OP_SUFFIX` name the `f_Op` slot; `parseTextOp` /
`parseNumberOp` / `parseDateOp` / `readFilterOp` read it;
`isValuelessFilterOp` / `isListFilterOp` / `isBetweenFilterOp` classify
operand shape. `TEXT_OP_LABEL_KEYS` /
`NUMBER_OP_LABEL_KEYS` / `DATE_OP_LABEL_KEYS` map each token to a
`TableLabels` key. `formatFilterChip` / `filterOpLabel` / `isEmptyRowValue`
/ `parseListOperand` / `parseNumberList` / `isFilterOpKey` are the
helpers. `useTextFilterWidget` returns a `TextFieldWidget`. Relative
windows: `RELATIVE_NAMED` / `RELATIVE_PRESETS` /
`RELATIVE_PRESET_LABEL_KEYS` / `RelativeDateToken` / `RelativeDateRange` /
`RelativePreset` / `parseRelativeToken` / `isRelativeDateToken` /
`countedRelativeToken` / `splitRelativeToken` / `joinRelativeToken` /
`relativeTokenLabel` / `resolveRelativeRange`. AND/OR groups:
`FILTER_TREE_PARAM` / `FILTER_TREE_VERSION` / `parseFilterTree` /
`serializeFilterTree` / `isActiveFilterTree` / `evaluateFilterTree` /
`conditionToExtra` over a `QueryFilterGroup` of `QueryCondition`s
(`isFilterGroup` narrows a child). Each adapter's `FilterTreeBuilder`
sits at the top of the Filters form when `source.setFilterTree` is
set; `toolbarShowsFilters` keeps the toolbar button in header mode
for that tree. The engine stores `ft=1.{…}` and evaluates the tree
on the frontend tier (ANDed with the flat extra bag). `useTableData`
wires `evaluateFilterTree` itself; a host that calls `useFrontendData`
directly passes `filterTreeFn` over the same defs as `filterFn`. A
server that declares `supports.filterTree` receives the same tree on
`query.filterTree`. Tree leaves become chips via `useFilterTreeChips`;
Clear all drops `ft`. See [filter-tree](./filter-tree.md).
- **Facet counts**: `computeFilterFacets` / `rowsExcludingFilter` /
`FacetMap` / `FacetCounts` count what selecting a value _would_ keep —
the filtered set with that facet's own filter removed. Frontend
`useTableData` computes them from `allSearchedRows` (after search,
before extras). A server that declares `supports.facets` receives
`query.facets` (checklist keys) and returns the same map on the page;
`useQuerySource` / `useServerData` surface it as `source.facets`.
`useChecklistFilter` prefers that map over `allFilteredRows`.
- **Type registry**: `FilterTypeSpec` is one type — widget kind, operators,
predicate, chips, tree projection, optional `render`. Built-ins
(`builtInFilterSpecs` / `defaultFilterRegistry`) are the first
consumers; `filterTypes` on the table merges extras via
`resolveFilterRegistry` / `createFilterRegistry`. A custom type registers
through `TableFeatureHost.registerFilterType` / `extendFilterType` in
`feature.setup(host)`, or `features={[filterTypes(specs)]}`. `filterWidgetKind` / `filterTypeOps` /
`filterTypeDefaultOp` / `filterTypeSpec` / `renderRegisteredFilter`
look a spec up. A custom type with `widget: "text"` draws the text
widget; `extend("text", { ops })` adds operators without forking.
`emptyFilterRegistry` seeds a registry from scratch.
`FilterTypeRegistry` / `FilterWidgetKind` / `FilterWidgetRenderProps`
are the types.
- **Header filter row**: compose `headerFilters()` and set
`filtersMode="header"` (see [feature composition](./features.md))
mounts each adapter's `FilterHeaderRow` / `FilterHeaderControl` over
`FilterHeaderChrome` / `FilterHeaderControlChrome`. Helpers
`filterDefForColumn` / `headerFilterStickTop` stay on core. The row
sits under the leaf header and hides the toolbar Filters button
(`resolveFilterMode` / `FilterChromeMode`).
Pads and column spacers match the header so sticky, pin offsets, and
column windowing stay aligned. A def whose bag key differs from the
column key sets `column` (`key: "name"` under `column: "person"`). Ant Design keeps the control inside the
header cell so `fixed` columns stay on antd's own header. Compact
range inputs default the operator to `gte` (no picker in the header);
checklist / multiSelect open a closed menu of checkboxes, not a native
`