React table data — client, server, one API
One <DataTable>, 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
Section titled “Example”1. Frontend — data
Section titled “1. Frontend — data”Pass the rows; the table filters, sorts, and pages them in memory.
// 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 ( <DataTable data={PEOPLE} columns={[{ key: "name", sortable: true }, { key: "role" }]} rowKey={(r) => r.id} /> );}2. Server — data + total + loading + onQueryChange
Section titled “2. Server — data + total + loading + onQueryChange”Your API paginates; the table owns the query state and tells you when to fetch.
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<Person[]>([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false);
return ( <DataTable data={rows} total={total} loading={loading} onQueryChange={async (query, { signal }) => { 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
Section titled “What the query carries, and how it grows”Every server tier receives one consolidated TableQuery:
{ (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:
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
Section titled “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.
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<TableQueryParams>): Promise<PaginatedResponse<Person>> { 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<Person>;}
// Your query hook: fetch one page for the current params.function usePeopleQuery(params: Partial<TableQueryParams>) { 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<Person>({ usePaginatedQuery: usePeopleQuery }); return ( <DataTable source={source} columns={[{ key: "name", sortable: true }, { key: "role" }]} rowKey={(r) => r.id} /> );}Explicit mode — when inference isn’t what you meant
Section titled “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
Section titled “How it works”- Tier resolution is by what you pass:
sourcewins; otherwiseonQueryChangeselects the server tier; otherwisedataalone is the frontend tier. Mixing tiers dev-warns and usessource. - 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 backdata+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’ssignalaborts — forward it tofetchand out-of-order responses die at the source. - Full control: every source builder returns the same
TableSourcecontract, so the table can’t tell in-memory from server data — switch tiers without touching the UI. - Column
filtershorthands and thefiltersarray drive widgets, chips, and URL parsing in all three tiers; only the frontend tier also applies the row predicate (the other tiers receivequery.filtersinstead).
What a source can do — capabilities
Section titled “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:
const source: TableSource<Person> = { ...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.
Cache keys for TanStack Query and SWR
Section titled “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.
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 elsequeryClient.invalidateQueries({ queryKey: tableQueryBaseKey(query, { scope: "people" }),});tableQueryBaseKeycovers what decides which rows: search, filters, sort, grouping, page size.tableQueryKeyappends 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
Section titled “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
signalfires. Forward it tofetchand an out-of-order response dies at the source rather than overwriting fresher rows. - Returning to a value re-requests it. Typing
a→ab→afires three times. The firstawas aborted the momentabsuperseded 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
Section titled “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<void> |
— | 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). |
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<TRow> |
— | Full control: a prebuilt source from useFrontendData / useQuerySource / your own. |
-
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 →
sourcewithuseQuerySource. -
The hooks behind the first two tiers —
useFrontendDataanduseServerData— are exported for headless use;useTableDatais the resolver that picks between them. -
useQuerySourceacceptsselectPage(aPageSelector— project your own page shape to{ rows, total? }when it isn’tPaginatedResponse),baseParams(static params merged into every call, e.g. a parent scope id), andsanitizeParams. Its query argument is typed structurally asInfiniteQueryLike, so TanStack Query stays a type-only peer. Seeexamples/mui-query-source.tsxfor a complete runnable version. -
selectPageis read through a ref: the projected rows recompute when fetched pages, pagination mode, orselectorKeychange — not when the selector function’s identity changes. MemoizingselectPagealone cannot trigger a re-projection. Pass the closed-over input asselectorKeywhen a memoized selector must re-run against unchanged fetched pages: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,});selectorKeyaccepts only a stablestringornumber. 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.