Data table: Server data
When a server pages and sorts the rows, give the data table one page at a time: set manualPagination and manualSorting, pass the page as data and the total as rowCount. The table reports page and sort changes; you fetch. The demo uses TanStack Query.
GET /people?page=1&size=5 · loading
import { keepPreviousData, QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { DataTable, type DataTablePaginationState, type DataTableSortingState } from "slotsmith/data-table";
import { fetchPeople } from "../shared/fake-api";
import { columns } from "../shared/people";
function PeopleTable() {
const [pagination, setPagination] = useState<DataTablePaginationState>({ pageIndex: 0, pageSize: 5 });
const [sorting, setSorting] = useState<DataTableSortingState>([]);
const [failing, setFailing] = useState(false);
const query = useQuery({
// A new page or order is a new key, so it is a new request.
queryKey: ["people", pagination, sorting],
// `failing` is left out of the key on purpose: ticking it changes how the next request ends, not which page it asks for.
queryFn: ({ signal }) => fetchPeople({ ...pagination, sorting }, { signal, fail: failing }),
// The last page stays on screen while the next one loads.
placeholderData: keepPreviousData,
// A switch back to the tab would refetch and dim the table for no visible change.
refetchOnWindowFocus: false,
});
// A failed request has no total: keep the last one, so the page count stays.
const [lastTotal, setLastTotal] = useState(0);
useEffect(() => {
if (query.data) setLastTotal(query.data.total);
}, [query.data]);
const total = query.data?.total ?? lastTotal;
return (
<>
<label style={{ display: "block", marginBlockEnd: "0.75rem" }}>
<input type="checkbox" checked={failing} onChange={(event) => setFailing(event.target.checked)} /> Fail the next requests
</label>
<DataTable
// Only the current page: the server has already sorted and cut it.
data={query.data?.rows ?? []}
columns={columns}
getRowId={(person) => person.id}
manualPagination
manualSorting
// The total across all pages, so the table can count them.
rowCount={total}
pagination={pagination}
onPaginationChange={setPagination}
sorting={sorting}
onSortingChange={(next) => {
setSorting(next);
// A new order starts again from the first page.
setPagination((current) => ({ ...current, pageIndex: 0 }));
}}
pageSizeOptions={[5, 10, 20]}
// Skeleton rows only while there is nothing to show, as on the first load.
loading={query.isPending}
error={query.error}
onRetry={() => query.refetch()}
// Dim the rows of the last page until the new ones arrive, and tell
// assistive technology the table is busy while any request runs.
style={{ opacity: query.isPlaceholderData ? 0.6 : 1 }}
aria-busy={query.isFetching || undefined}
/>
<p dir="ltr">
{`GET /people?page=${pagination.pageIndex + 1}&size=${pagination.pageSize}`}
{sorting.map((sort) => `&sort=${sort.desc ? "-" : ""}${sort.id}`).join("")}
{query.isFetching ? " · loading" : query.isError ? " · failed" : " · done"}
</p>
</>
);
}
export default function ServerData() {
// One client for the app; a real one sits at the root, next to your other providers.
const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { retry: false } } }));
return (
<QueryClientProvider client={client}>
<PeopleTable />
</QueryClientProvider>
);
}import type { Person } from "./people";
/** What the table asks for: one page, in an order. */
export type PeopleRequest = { pageIndex: number; pageSize: number; sorting: { id: string; desc: boolean }[] };
/** One page of people, and how many there are across all pages. */
export type PeoplePage = { rows: Person[]; total: number };
const GIVEN = ["Lena", "Omar", "Sofia", "Yuki", "Amir", "Chloe", "Jonas", "Priya"];
const FAMILY = ["Park", "Haddad", "Reyes", "Tanaka", "Nasser", "Martin"];
const ROLES = ["Engineer", "Designer", "Manager", "Analyst", "Support"];
const TEAMS = ["Platform", "Product", "Payments", "Growth", "Care"];
const STATUSES = ["Active", "Active", "Away", "Active", "On leave"];
const twoDigits = (value: number) => String(value).padStart(2, "0");
/** 48 people, built from their position so every request sees the same ones. */
const PEOPLE: Person[] = GIVEN.flatMap((given, givenIndex) =>
FAMILY.map((family, familyIndex) => {
const index = givenIndex * FAMILY.length + familyIndex;
return {
id: String(index + 1),
name: `${given} ${family}`,
role: ROLES[(index * 3) % ROLES.length]!,
team: TEAMS[(index * 7) % TEAMS.length]!,
status: STATUSES[(index * 2 + givenIndex) % STATUSES.length]!,
joined: `${2018 + (index % 7)}-${twoDigits(1 + ((index * 5) % 12))}-${twoDigits(1 + ((index * 11) % 28))}`,
};
}),
);
/** Waits `ms`, and rejects with an `AbortError` as soon as the signal aborts. */
function wait(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const cancelled = () => new DOMException("The request was cancelled.", "AbortError");
if (signal?.aborted) return reject(cancelled());
const timer = setTimeout(() => {
signal?.removeEventListener("abort", abort);
resolve();
}, ms);
const abort = () => {
clearTimeout(timer);
reject(cancelled());
};
signal?.addEventListener("abort", abort, { once: true });
});
}
/** Orders two people by the sorted columns, first column first; ties keep the stored order. */
function compare(a: Person, b: Person, sorting: PeopleRequest["sorting"]): number {
for (const { id, desc } of sorting) {
const left = a[id as keyof Person];
const right = b[id as keyof Person];
if (left !== right) return (left < right ? -1 : 1) * (desc ? -1 : 1);
}
return 0;
}
/**
* A stand-in for a paged endpoint: it sorts all 48 people by `sorting`, then
* answers the page asked for and the total, after 300 milliseconds. It stops
* as soon as `signal` aborts, and rejects when `fail` is set, the way a
* server that is down would.
*/
export async function fetchPeople(
{ pageIndex, pageSize, sorting }: PeopleRequest,
{ signal, fail = false }: { signal?: AbortSignal; fail?: boolean } = {},
): Promise<PeoplePage> {
await wait(300, signal);
if (fail) throw new Error("The server did not answer.");
const sorted = sorting.length === 0 ? PEOPLE : [...PEOPLE].sort((a, b) => compare(a, b, sorting));
const start = pageIndex * pageSize;
return { rows: sorted.slice(start, start + pageSize), total: PEOPLE.length };
}import type { DataTableColumnDef } from "slotsmith/data-table";
export type Person = { id: string; name: string; role: string; team: string; status: string; joined: string };
export const people: Person[] = [
{ id: "1", name: "Lena Park", role: "Engineer", team: "Platform", status: "Active", joined: "2021-03-08" },
{ id: "2", name: "Omar Haddad", role: "Designer", team: "Product", status: "Active", joined: "2020-11-16" },
{ id: "3", name: "Sofia Reyes", role: "Engineer", team: "Payments", status: "Away", joined: "2022-01-24" },
{ id: "4", name: "Yuki Tanaka", role: "Manager", team: "Platform", status: "Active", joined: "2019-06-03" },
{ id: "5", name: "Amir Nasser", role: "Analyst", team: "Growth", status: "Active", joined: "2023-02-13" },
{ id: "6", name: "Chloe Martin", role: "Engineer", team: "Payments", status: "On leave", joined: "2021-09-27" },
{ id: "7", name: "Jonas Weber", role: "Support", team: "Care", status: "Active", joined: "2022-07-11" },
{ id: "8", name: "Priya Nair", role: "Designer", team: "Product", status: "Away", joined: "2020-04-20" },
{ id: "9", name: "Diego Alvarez", role: "Engineer", team: "Growth", status: "Active", joined: "2023-05-30" },
{ id: "10", name: "Hana Kim", role: "Manager", team: "Care", status: "Active", joined: "2018-10-01" },
{ id: "11", name: "Tomas Novak", role: "Analyst", team: "Payments", status: "On leave", joined: "2022-12-05" },
{ id: "12", name: "Nadia Farouk", role: "Support", team: "Care", status: "Active", joined: "2024-01-15" },
];
export const columns: DataTableColumnDef<Person>[] = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "role", header: "Role" },
{ accessorKey: "team", header: "Team" },
{ accessorKey: "status", header: "Status" },
{ accessorKey: "joined", header: "Joined" },
];The API here is a stand-in that answers after 300 ms; its code is in the fake-api.ts tab. Move to another page or click a header: the line under the table shows the request. Tick the checkbox, change the page, and the table shows the error with a retry button.
Paging and sorting on the server
The page and the sort are controlled state. Each change is a new request, and the table does no paging or sorting of its own.
manualPaginationsaysdatais already one page.rowCountis the total across all pages; the table needs it to count the pages and to disable the next-page button on the last one.paginationandonPaginationChangehold{ pageIndex, pageSize }, withpageIndexcounting from 0. See pagination.manualSortingsays the rows arrive already sorted.sortingandonSortingChangehold a list of{ id, desc }; the headers still show the order and carryaria-sort. See sorting and selection.
The demo goes back to the first page when the sort changes. The table does not do that itself under manual pagination, so onSortingChange sets pageIndex to 0.
Loading
loading set to true replaces the body with skeleton rows, one per row of the page size. The root then has data-status="loading" and aria-busy="true", and the skeleton rows are hidden from assistive technology. Each skeleton cell is the Skeleton slot.
The demo passes loading only while there is nothing to show. With placeholderData: keepPreviousData, TanStack Query keeps the last page as data while the next one loads, so isPending is true on the first load alone. The demo dims the table while isPlaceholderData is true, and sets aria-busy on the root while any request runs (a root prop you pass replaces the one the table sets). Without the placeholder, every page change would show skeleton rows.
Errors and empty results
errorset to any value butnull,undefinedorfalseshows theErrorslot in place of the rows: theerrorlabel in arole="alert"element. The root hasdata-status="error".onRetryadds a button named by theretrylabel. The demo calls the query’srefetch.- A page with no rows shows the
Emptyslot with theemptylabel, anddata-status="empty".
loading wins over error, and error over empty. If a page other than the first comes back empty, for example after its last row was deleted, the table steps back a page and reports it through onPaginationChange.
Other ways to fetch
Nothing in the table depends on TanStack Query. Any code that turns { pageIndex, pageSize, sorting } into { rows, total } fits: a fetch in an effect, SWR, or a loader from your framework. Pass its rows, its total, its pending flag and its error to the same four props.