Data tiers
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/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/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} /> );}3. Full control — source
Section titled “3. Full control — source”Build a TableSource yourself — useBackendData 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/unstyled" — same props everywhere.import { DataTable, type PaginatedResponse, type TableQueryParams, useBackendData,} 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 = useBackendData<Person>({ usePaginatedQuery: usePeopleQuery }); return ( <DataTable source={source} columns={[{ key: "name", sortable: true }, { key: "role" }]} rowKey={(r) => r.id} /> );}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).
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 }) => void | Promise<void> | — | Server tier: fired per consolidated query change, once on mount included. |
error | Error | null | null | Forwarded error to display. |
source | TableSource<TRow> | — | Full control: a prebuilt source from useFrontendData / useBackendData / 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 →
sourcewithuseBackendData. - The hooks behind the first two tiers —
useFrontendDataanduseServerData— are exported for headless use;useTableDatais the resolver that picks between them. useBackendDataacceptsselectPage(when your page shape isn’tPaginatedResponse),baseParams(static params merged into every call, e.g. a parent scope id), andsanitizeParams. Seeexamples/mui-backend.tsxfor a complete runnable version.- 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.