Skip to content

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.

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}
/>
);
}

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.

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.

  • 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 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).

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.

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

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 aaba 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.

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 → 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 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:

    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.