Server-side data
When the rows live behind an API, the grid renders the page it is given and reports every change of state, and you refetch. The demo fakes a server holding 5,000 people and fetches a page of 20 on every change of page, sort, filter or search.
import * as React from "react";import { QueryClient, QueryClientProvider, keepPreviousData, useQuery,} from "@tanstack/react-query";import type { ColumnFiltersState, PaginationState, SortingState } from "@tanstack/react-table";
import { DataGrid, createDataGridColumns, filterFn_facet, filterFn_textOp, filterFn_range, rankMatch,} from "@/components/ui/data-grid";
import { makePeople, PLAN_OPTIONS, STATUS_OPTIONS, type Person,} from "@/lib/data-grid-demo-data";
// ── A pretend API: 5,000 rows on "the server", paged, sorted and filtered there ──────────────────const ALL = makePeople(5_000, 23);
interface Query { pagination: PaginationState; sorting: SortingState; columnFilters: ColumnFiltersState; globalFilter: string;}
async function fetchPeople(q: Query): Promise<{ rows: Person[]; total: number }> { await new Promise((r) => setTimeout(r, 350 + Math.random() * 250)); let rows = ALL; if (q.globalFilter) rows = rows.filter((p) => rankMatch(`${p.name} ${p.email} ${p.team}`, q.globalFilter) > 0); for (const f of q.columnFilters) { const fn = f.id === "status" || f.id === "plan" ? filterFn_facet : f.id === "mrr" ? filterFn_range : filterFn_textOp; rows = rows.filter((p) => fn.filter(p[f.id as keyof Person], f.value, null as never, f.id)); } for (const s of [...q.sorting].reverse()) { rows = [...rows].sort((a, b) => { const x = a[s.id as keyof Person] as string | number | Date; const y = b[s.id as keyof Person] as string | number | Date; const r = x < y ? -1 : x > y ? 1 : 0; return s.desc ? -r : r; }); } const start = q.pagination.pageIndex * q.pagination.pageSize; return { rows: rows.slice(start, start + q.pagination.pageSize), total: rows.length };}
const col = createDataGridColumns<Person>();const columns = col.columns([ col.text("name", { header: "Name" }), col.text("email", { header: "Email", size: 240 }), col.enum("status", { header: "Status", options: STATUS_OPTIONS }), col.enum("plan", { header: "Plan", options: PLAN_OPTIONS }), col.text("team", { header: "Team" }), col.currency("mrr", { header: "MRR", currency: "USD" }),]);
const client = new QueryClient();
function ServerGrid() { // The grid owns the state; we read it back through the controlled `table` escape hatch. const [pagination, setPagination] = React.useState<PaginationState>({ pageIndex: 0, pageSize: 20, }); const [sorting, setSorting] = React.useState<SortingState>([]); const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]); const [globalFilter, setGlobalFilter] = React.useState("");
const query = useQuery({ queryKey: ["people", pagination, sorting, columnFilters, globalFilter], queryFn: () => fetchPeople({ pagination, sorting, columnFilters, globalFilter }), placeholderData: keepPreviousData, });
return ( <DataGrid key="server" data={query.data?.rows ?? []} columns={columns} getRowId={(row) => row.id} server={{ rowCount: query.data?.total ?? 0 }} pagination={{ pageSize: 20 }} loading={query.isFetching} error={query.error ? query.error.message : null} onRetry={() => query.refetch()} table={{ state: { pagination, sorting, columnFilters, globalFilter }, onPaginationChange: (u) => setPagination((old) => (typeof u === "function" ? u(old) : u)), onSortingChange: (u) => setSorting((old) => (typeof u === "function" ? u(old) : u)), onColumnFiltersChange: (u) => { setColumnFilters((old) => (typeof u === "function" ? u(old) : u)); setPagination((p) => ({ ...p, pageIndex: 0 })); }, onGlobalFilterChange: (u) => { setGlobalFilter((old) => (typeof u === "function" ? u(old) : u) ?? ""); setPagination((p) => ({ ...p, pageIndex: 0 })); }, }} // Facets come from the server too; here we hand the grid the whole catalogue. grouping={false} /> );}
/** Server-side paging, sorting and filtering with TanStack Query; the grid only renders a page. */export default function DataGridServer() { return ( <QueryClientProvider client={client}> <ServerGrid /> </QueryClientProvider> );}Use it
Section titled “Use it”The server option switches the grid to manual mode:
<DataGrid data={page.rows} columns={columns} server={{ rowCount: page.total }} pagination={{ pageSize: 20 }}/>rowCountis the total across all pages. It drives the page count, the x of y text andaria-rowcount.- Pagination is always manual with
server: the grid shows the rows you give it as one page and never resets the page index on its own (autoResetPageIndexis off), so it is you who sends the user back to page 1 when a filter changes. - Sorting and filtering are the server’s by default (
manualSorting,manualFiltering); the grid changes its indicators and reports the state, nothing else. Setsorting: falseorfiltering: falseto have the grid sort or filter the current page locally instead. - Grouping and expanding stay local unless you say
grouping: trueorexpanding: true, in which case the grid expects the rows already grouped or expanded.
Reading the state back
Section titled “Reading the state back”Everything the grid knows is TanStack state. The table option is the escape hatch to any TanStack
table option, applied last, and that includes state and the on*Change callbacks:
table={{ state: { pagination, sorting, columnFilters, globalFilter }, onPaginationChange: (u) => setPagination((old) => (typeof u === "function" ? u(old) : u)), onSortingChange: (u) => setSorting((old) => (typeof u === "function" ? u(old) : u)), // …}}A slice you put in state is controlled: the grid reads it from you and calls the matching
on*Change instead of changing it. TanStack hands these callbacks either a value or an updater
function, hence the typeof u === "function" dance. Slices you leave out stay the grid’s own.
The example, line by line
Section titled “The example, line by line”-
A pretend API.
gALLis 5,000 rows generated once;fetchPeople(q)waits a few hundred milliseconds, then filters, sorts and slices them. It reuses the grid’s own filter functions< (co ded="irau">tofilterFn_fac (co>et<, co ded="irau">tofilterFn_ran, co>ge<, co ded="irau">tofilterFn_text, co>Op) antorankMat ch so the fake server agrees with what the filter UI sends, which is exactly what your real server has to do: a facet filter value is an array of chosen values, a range filter ito{ min, max}, a text filter ito{ op, value}, the search is a strin .<
>l<
pstro>ngColum
pstro>ns are ordinary columns. Nothing marks them as server-side
>.<
>l<
pstro>ngFour pieces of sta
pstro>te in the component<: co ded="irau">topagination< (co ded="irau">topageInd (co>ex an autoloading={query.isFetch inautot able hands the four slices to the grid as controlled state and receives changes baautogrouping={fa lse} removes the Group menu. Grouping a server page locally would group 20 rows
and show misleading counts; you can leave it on if that is fine for your data, or implemautoQueryClientProv ider wraps it, as it would in your app r
>o
Facets on the server
Section titled “Facets on the server”The facet filter list is built from two sources: the column’s declared options first, in their
order, then any other value present in the rows the grid has. In server mode those rows are the
current page, so:
- For
enumandtagscolumns, declareoptionswith the full set of values. The list is complete even when the page shows only a few of them, and the counts beside each value describe the current page. - A text or other column without
optionslists only the values on the page. If users need the full set, fetch it from the server and pass it asoptions. - The range filter’s suggested
minandmaxcome from the page as well; the user can type any bound.
A cursor-paged variant
Section titled “A cursor-paged variant”Many APIs page by cursor and don’t report a total. The grid wants a pageIndex and a rowCount;
bridge them by remembering the cursor for each page you have seen:
const [pageIndex, setPageIndex] = React.useState(0)const pageSize = 50const cursors = React.useRef<Array<string | null>>([null]) // cursors[i] opens page i
const query = useQuery({ queryKey: ["orders", pageIndex, sorting], queryFn: () => api.orders.list({ cursor: cursors.current[pageIndex], limit: pageSize, sort: sorting }), placeholderData: keepPreviousData,})
React.useEffect(() => { if (query.data?.nextCursor) cursors.current[pageIndex + 1] = query.data.nextCursor}, [query.data, pageIndex])
// Claim one more page while a next cursor exists; the count grows as the user goes.const known = (pageIndex + 1) * pageSizeconst rowCount = query.data?.nextCursor ? known + pageSize : known - pageSize + (query.data?.rows.length ?? 0)
<DataGrid data={query.data?.rows ?? []} columns={columns} server={{ rowCount }} pagination={{ pageSize, pageSizeOptions: [pageSize] }} pager={{ showGoTo: false }} loading={query.isFetching} table={{ state: { pagination: { pageIndex, pageSize }, sorting }, onPaginationChange: (u) => setPageIndex((typeof u === "function" ? u({ pageIndex, pageSize }) : u).pageIndex), onSortingChange: (u) => { setSorting((old) => (typeof u === "function" ? u(old) : u)) cursors.current = [null] // a new order invalidates every cursor setPageIndex(0) }, }}/>The pager’s Page x of y then shows the pages known so far, and showGoTo: false hides the
jump-to-page input, since there is no cursor for a page nobody has reached.
Options
Section titled “Options”The server option:
| Option | Type | Default | What it does |
|---|---|---|---|
rowCount |
number |
required | Total rows across all pages. |
pageCount |
number |
from rowCount |
Supply it when your API reports pages rather than rows. |
sorting |
boolean |
true |
Sorting is the server’s job (manualSorting). false sorts the page locally. |
filtering |
boolean |
true |
Filtering and search are the server’s job (manualFiltering). |
grouping |
boolean |
false |
true expects grouped rows from you (manualGrouping). |
expanding |
boolean |
false |
true expects expanded rows from you (manualExpanding). |
What server sets on the table, for the record: manualPagination: true, rowCount,
pageCount, autoResetPageIndex: false, and the four manual flags above. grid.isServer is true.
See also
Section titled “See also”- Loading and empty states.
- URL state: the query in the address bar, shareable.
- Virtualization: when the rows are already in the browser.