DataTable
Smart, data-driven table on TanStack Table — sorting, filtering, pagination, selection, and a responsive stacked-card mode, rendered through the Table primitive.
DataTable wraps TanStack Table (headless row/column engine) and renders through the styled Table primitive. Define columns once, hand it data, and sorting / filtering / pagination round-trip as react-table-native state — the same state a useState dispatch or a TanStack Query queryKey can bind to directly, with no kit-specific adapter in between.
import { DataTable } from "@42/ui-react/data-table";
const columns = [
{ accessorKey: "name", header: "Name", enableHiding: false },
{ accessorKey: "role", header: "Role" },
{ accessorKey: "commits", header: "Commits", meta: { align: "end" } },
];
<DataTable columns={columns} data={users} />;Every column is sortable by default (click the header) — no extra wiring. A power user who wants total control composes the Table parts with their own useReactTable instance and skips DataTable entirely.
Basic
Columns are plain ColumnDef objects — @42/ui-react/data-table re-exports createColumnHelper for full type narrowing in a real app, but a plain object literal works identically at runtime (as below). meta.align right-aligns a numeric column.
| Ada Lovelace | Staff | 128 |
| Alan Turing | Student | 57 |
| Grace Hopper | Staff | 203 |
Rows per page
1
Code
const columns = [ { accessorKey: 'name', header: 'Name' }, { accessorKey: 'role', header: 'Role' }, { accessorKey: 'commits', header: 'Commits', meta: { align: 'end' } },];const users = [{ id: '1', name: 'Ada Lovelace', role: 'Staff', commits: 128 },{ id: '2', name: 'Alan Turing', role: 'Student', commits: 57 },{ id: '3', name: 'Grace Hopper', role: 'Staff', commits: 203 },];<DataTable columns={columns} data={users} />Resizable columns
By default (sizing="fixed") every column renders at a real, stable width — no more reflow when a filter or sort changes the visible rows. The underlying <table> is table-layout: fixed, sized off react-table's own size / minSize (plain ColumnDef fields — a column with neither renders at react-table's 150px default, a real behavior change from DataTable's initial release, where columns sized themselves from whatever row content happened to be visible). Set enableColumnResizing to let the end user drag a column's trailing edge to their own preference — the handle wires react-table's built-in mouse and touch handler, and supports ArrowLeft / ArrowRight when focused. To persist resized widths across reloads, own columnSizing yourself — the kit's useLocalStorage hook (also exported from @42/ui-react) returns a [value, setValue] pair that plugs straight into columnSizing / onColumnSizingChange, keeping every open tab on the same value in sync too. Set sizing="auto" to opt back into content-based sizing instead — see below.
fillLastColumn (on by default) stretches the last visible column to absorb any width left over once every other column has its own size, so the table fills its container instead of leaving a gap when the column sizes add up to less than the available space — the two previews above already have it on (widen your browser to see the Commits column stretch). Set it to false to give the last column its own size/minSize back, gap and all — its resize handle also returns, since there's a real width for the drag to adjust again.
| Ada Lovelace | Staff | 128 |
| Alan Turing | Student | 57 |
| Grace Hopper | Staff | 203 |
Rows per page
1
Code
const columns = [ { accessorKey: 'name', header: 'Name', size: 200, minSize: 120 }, { accessorKey: 'role', header: 'Role', size: 120 }, { accessorKey: 'commits', header: 'Commits', meta: { align: 'end' } },];const [columnSizing, setColumnSizing] = useLocalStorage({key: 'users-table',defaultValue: {},});<DataTable columns={columns} data={users} enableColumnResizing columnSizing={columnSizing} onColumnSizingChange={setColumnSizing}/>Content-based sizing
sizing="auto" opts back into the browser's own table-layout: auto — columns size themselves from whatever rows are currently visible, size / minSize are ignored, and enableColumnResizing has no effect (there's no fixed width for a drag to adjust). This is DataTable's original pre-resize behavior: reach for it only when a table's content is short and uniform enough that reflow-on-filter isn't a real concern, since that's the exact tradeoff sizing="fixed" (the default) exists to avoid.
| Ada Lovelace | Staff | 128 |
| Alan Turing | Student | 57 |
| Grace Hopper | Staff | 203 |
Rows per page
1
Code
<DataTable columns={columns} data={users} sizing="auto" />Column filters
Set meta.filter on a column to add a header filter affordance — an icon button opening a popover whose body matches the discriminant: text (debounced), select / multi-select, number, date, or date-range. number is a plain min/max (from/to) range: fill either or both, and the filter derives whether that's "at least," "at most," or "between" — no operator to pick. date / date-range open the same calendar + presets(+ time) popover DatePicker itself uses — see Date and date-range below. DataTable also wires the matching client-side filterFn automatically from meta.filter.type, so getFilteredRowModel filters correctly without manualFiltering. An active filter shows a dot indicator, not just a color change.
Staff Student | ||
|---|---|---|
| Ada Lovelace | Staff | 128 |
| Alan Turing | Student | 57 |
| Grace Hopper | Staff | 203 |
| Katherine Johnson | Staff | 84 |
Rows per page
1
Code
const columns = [ { accessorKey: 'name', header: 'Name', meta: { filter: { type: 'text', placeholder: 'Search name…' } } }, { accessorKey: 'role', header: 'Role', meta: { filter: { type: 'select', multiple: true, options: ['Staff', 'Student'] } }, }, { accessorKey: 'commits', header: 'Commits', meta: { align: 'end', filter: { type: 'number', min: 0, max: 2000 } }, },];<DataTable columns={columns} data={users} />Single select
Omit multiple for a single-value picker — the popover closes itself the instant a value is
picked, matching Select's own close-on-pick habit (multi-select stays open so more values can
be toggled).
Staff Student | |
|---|---|
| Ada Lovelace | Staff |
| Alan Turing | Student |
| Grace Hopper | Staff |
Rows per page
1
Code
const columns = [ { accessorKey: 'name', header: 'Name' }, { accessorKey: 'role', header: 'Role', meta: { filter: { type: 'select', options: ['Staff', 'Student'] } }, },];<DataTable columns={columns} data={users} />Date and date-range
date / date-range open a popover with the same calendar grid + presets rail (+ time row under
withTime) DatePicker itself uses — composed directly from its exported
Root/Content/Presets/Calendar/TimePanelRow parts, not a separate implementation. A click
picks the range's start, a second click its end. date's stored value still derives an operator
from that pair — a lone click is "on or after," two clicks is "between" — while date-range
always treats the pair as a plain inclusive range, each bound independently optional. Because the
calendar's clicks are ordered (start, then end), there's no interaction that produces an
upper-bound-only "on or before" value the way the old two-independent-inputs UI could — that
operator is still fully supported, just settable only programmatically via controlled
columnFilters, not through this popover.
presets (mirroring DatePicker's own prop, on "Hired" below) adds a shortcuts sidebar — true
for a mode-appropriate default set, or a custom DatePreset[]. withTime (on "Last review")
adds an embedded time row and compares exact instants rather than whole calendar days.
|
From time To time | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Ada Lovelace | 2021-03-15 | 2024-01-10T09:15:00 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Alan Turing | 2022-07-01 | 2024-06-22T16:45:00 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Grace Hopper | 2019-11-20 | 2023-09-05T11:00:00 |
Rows per page
1
Code
const columns = [ { accessorKey: 'name', header: 'Name' }, { accessorKey: 'hired', header: 'Hired', meta: { filter: { type: 'date', presets: true } } }, { accessorKey: 'lastReview', header: 'Last review', meta: { filter: { type: 'date-range', withTime: true } }, },];<DataTable columns={columns} data={users} />Global search, column visibility & a title bar
title and toolbarActions render a Table.Toolbar above the table; enableGlobalFilter adds a debounced search box bound to globalFilter, and enableColumnVisibility adds a checkbox menu of the table's columns. The bar only renders when at least one of these is set.
A column with enableHiding: false still appears in that menu (and in the stacked layout's own "Columns" section), checked and disabled — it's listed so the end user can see it exists, just can't be turned off. For a utility/action column that was never a real visibility choice in the first place (a trailing "view" button, say), set meta.hideFromMenu instead — it's excluded from both lists entirely, independent of enableHiding.
Users
Staff Student | ||
|---|---|---|
| Ada Lovelace | Staff | 128 |
| Alan Turing | Student | 57 |
| Grace Hopper | Staff | 203 |
Rows per page
1
Code
<DataTable columns={columns} data={users} title="users" toolbarActions={<Button size="sm">Create user</Button>} enableGlobalFilter enableColumnResizing enableColumnVisibility/>Non-text headers (meta.label)
When a column's header isn't plain text — an icon, say — meta.label supplies the text used
everywhere a label would otherwise fall back to a string header: the column-visibility menu
above, and a stacked card's label in responsive="stack" below.
| Ada Lovelace | Verified |
| Alan Turing | Pending |
Rows per page
1
Code
<DataTable columns={[ { accessorKey: 'name', header: 'Name' }, { accessorKey: 'verified', header: () => <ShieldCheckIcon className="size-4" />, meta: { label: 'Verified' }, cell: ({ getValue }) => ( <Badge color={getValue() ? 'green' : 'gray'} size="sm"> {getValue() ? 'Verified' : 'Pending'} </Badge> ), }, ]} data={users} enableColumnVisibility/>Pagination
DataTable renders a pagination footer automatically — a page-size Select, prev/next, and a page-number indicator whose tooltip reveals the row range and total, all driven off getPaginationRowModel() for client-side data (the default). Flip manualPagination for server-owned pages (see Query integration below). Set enablePagination={false} to hide the footer entirely; override its wording with translations (see API below).
| Contributor 1 | Staff | 7 |
| Contributor 2 | Student | 14 |
| Contributor 3 | Student | 21 |
| Contributor 4 | Staff | 28 |
| Contributor 5 | Student | 35 |
Rows per page
1
Code
<DataTable columns={columns} data={/* 24 users */} initialState={{ pagination: { pageIndex: 0, pageSize: 5 } }}/>Custom page sizes
pageSizeOptions replaces the default [10, 20, 50] choices in the page-size Select.
| Contributor 1 | Staff | 7 |
| Contributor 2 | Student | 14 |
| Contributor 3 | Student | 21 |
| Contributor 4 | Staff | 28 |
| Contributor 5 | Student | 35 |
Rows per page
1
Code
<DataTable columns={columns} data={/* 24 users */} pageSizeOptions={[5, 15, 30]} initialState={{ pagination: { pageIndex: 0, pageSize: 5 } }}/>Unknown total (cursor-style)
Omit rowCount (and set pageCount={-1}, react-table's own "unknown" sentinel) for a
cursor-backed source that can't say how many pages there are — the footer degrades to "Page N"
with prev/next-only, gated by hasNextPage instead of a last-page jump.
| Contributor 1 |
| Contributor 2 |
| Contributor 3 |
| Contributor 4 |
| Contributor 5 |
Rows per page
1
Code
function CursorTable() { const [pageIndex, setPageIndex] = useState(0); const pageSize = 5; const page = fetchCursorPage(pageIndex, pageSize); // your own cursor source return ( <DataTable columns={columns} data={page.items} manualPagination pageCount={-1} hasNextPage={page.hasMore} pagination={{ pageIndex, pageSize }} onPaginationChange={(updater) => { const next = typeof updater === 'function' ? updater({ pageIndex, pageSize }) : updater; setPageIndex(next.pageIndex); }} /> );}Row selection
enableRowSelection auto-injects a leading checkbox column — an indeterminate select-all in the header, a per-row checkbox with a visually-hidden accessible name. Selected rows tint from the --c-soft slot var.
| Ada Lovelace | Staff | 128 | |
| Alan Turing | Student | 57 | |
| Grace Hopper | Staff | 203 |
Rows per page
1
Code
<DataTable columns={columns} data={users} enableRowSelection />Conditional selection
enableRowSelection also takes a predicate — (row) => boolean — to disable selecting some rows
instead of all-or-nothing. The header checkbox's indeterminate/select-all state only ever
accounts for the currently selectable rows.
| Ada Lovelace | Staff | 128 | |
| Alan Turing | Student | 57 | |
| Grace Hopper | Staff | 203 |
Rows per page
1
Code
<DataTable columns={columns} data={users} enableRowSelection={(row) => row.original.role === 'Staff'}/>Row click
onRowClick fires unless the click landed on a nested interactive element (a link, button,
checkbox, menu, …) — so it composes safely with row selection or a trailing action column instead
of stealing their clicks. It's a mouse-only convenience, though: rows aren't themselves focusable,
so onRowClick should never be a row's only affordance — pair it with a real link or button,
as the "View" column below does.
| Ada Lovelace | Staff | |
| Alan Turing | Student | |
| Grace Hopper | Staff |
Rows per page
1
Click a row (or its View button).
Code
function UsersTable() { const [selected, setSelected] = useState(null); return ( <> <DataTable columns={[ { accessorKey: 'name', header: 'Name' }, { accessorKey: 'role', header: 'Role' }, { id: 'view', header: '', cell: ({ row }) => ( <Button size="xs" variant="subtle" onClick={() => setSelected(row.original)}> View </Button> ), }, ]} data={users} onRowClick={setSelected} /> <Text size="sm" c="muted"> {selected ? \`Selected: \${selected.name}\` : 'Click a row (or its View button).'} </Text> </> );}Loading, empty & error states
loading shows skeleton rows on first load, then dims existing rows on a refetch (pairs with TanStack Query's keepPreviousData). empty and error replace the body — error is a role="alert" region, and onRetry is a real callback for its "Retry" button, not decoration.
Rows per page
1
No users match your filters.Some description | |
Rows per page
1
Couldn't load users. Try again. | |
Rows per page
1
Code
<div className="flex w-full flex-col gap-6"> <DataTable columns={[ { accessorKey: "name", header: "Name" }, { accessorKey: "role", header: "Role" }, ]} data={[]} loading /> <DataTable columns={[ { accessorKey: "name", header: "Name" }, { accessorKey: "role", header: "Role" }, ]} data={[]} empty={{ title: "No users match your filters.", description: "Some description", }} /> <ErrorRetryDemo /></div>Refetching
Setting loading while data already has rows dims the existing rows instead of swapping in
skeletons — the treatment a keepPreviousData-style refetch gets, versus the from-empty first
load above.
| Ada Lovelace | Staff |
| Alan Turing | Student |
| Grace Hopper | Staff |
Rows per page
1
Code
function UsersTable() { const [loading, setLoading] = useState(false); const refetch = () => { setLoading(true); fetchUsers().then(() => setLoading(false)); }; return ( <> <Button size="sm" variant="light" color="gray" onClick={refetch} loading={loading}> Refetch </Button> <DataTable columns={columns} data={users} loading={loading} /> </> );}Column-specific skeleton content (meta.skeleton)
The default skeleton bar is sized to match a plain text cell at the table's current density automatically, with no per-column setup — both the scroll-table and stacked-card views render it through the shared Skeleton component. A column whose real cell isn't text — a thumbnail, an icon button — sets meta.skeleton to override it with a same-shaped placeholder instead, so the row doesn't visibly resize once real data replaces the skeleton. Build it from DATA_TABLE_SKELETON_CLASS (also exported from @42/ui-react/data-table) to match the default bar's shimmer/fill/radius.
Rows per page
1
Code
import { DATA_TABLE_SKELETON_CLASS } from '@42/ui-react/data-table';<DataTable columns={[ { accessorKey: 'name', header: 'Name' }, { accessorKey: 'avatar', header: '', meta: { align: 'center', skeleton: <div className={cn(DATA_TABLE_SKELETON_CLASS, 'size-8 rounded-full')} />, }, }, ]} data={[]} loading/>Presentation
size, striped, and stickyHeader forward straight to the underlying Table
primitive.
Density
| Ada Lovelace | Staff |
| Alan Turing | Student |
Rows per page
1
| Ada Lovelace | Staff |
| Alan Turing | Student |
Rows per page
1
| Ada Lovelace | Staff |
| Alan Turing | Student |
Rows per page
1
Code
<DataTable columns={columns} data={users} size="xs" /><DataTable columns={columns} data={users} size="md" /><DataTable columns={columns} data={users} size="xl" />Striped rows + sticky header
stickyHeader pins the header row while the body scrolls — it (and stickyFooter) need a
bounded height on the table (or Table.Content) to have anything to stick within.
stickyFooter is the same idea for a Table.Foot totals row, but DataTable doesn't render a
footer row itself (no column footer def, no foot slot yet) — reach for
Table's own composition if you need one.
| Contributor 1 | Staff |
| Contributor 2 | Student |
| Contributor 3 | Student |
| Contributor 4 | Staff |
| Contributor 5 | Student |
| Contributor 6 | Student |
| Contributor 7 | Staff |
| Contributor 8 | Student |
| Contributor 9 | Student |
| Contributor 10 | Staff |
Rows per page
1
Code
<DataTable columns={columns} data={users} striped stickyHeader className="max-h-64" />fillLastColumn={false}
Off, the last column keeps its own size/minSize — and its resize handle — instead of
stretching to absorb the container's leftover width (contrast with the resizing previews above,
which all leave this on).
| Ada Lovelace | Staff | 128 |
| Alan Turing | Student | 57 |
Rows per page
1
Code
<DataTable columns={columns} data={users} enableColumnResizing fillLastColumn={false}/>Slot overrides (classNames)
Every part of the render tree — root, table, head, headerRow, headerCell, body, row,
cell, empty, error, toolbar, pagination — takes its own class through classNames,
merged with (not replacing) the part's own styling.
| Ada Lovelace | Staff |
| Alan Turing | Student |
Rows per page
1
Code
<DataTable columns={columns} data={users} classNames={{ headerRow: 'bg-brand-50 dark:bg-brand-950', row: 'hover:bg-brand-50/50 dark:hover:bg-brand-950/50', }}/>Responsive: stacked cards
responsive="stack" renders each row as a Card of label: value pairs below a container-query breakpoint (@lg), instead of the <table> row — useful when columns can't reasonably fit a narrow viewport. Both renderings exist in the DOM; only one is visible at a given container width. This is an explicit accessibility tradeoff: stacked cards drop the row/column semantics screen-reader users rely on, so "scroll" (the default) stays the SR-faithful path. The example below is wrapped in a narrow container to force the stacked view regardless of your screen width.
A stacked table has no header row, so a "Table options" button appears above the cards whenever there's a sortable column, a filterable column, or (with enableColumnVisibility) a hideable column — it opens a bottom Drawer with the same sort toggles, filter controls, and column-visibility checkboxes the desktop header/toolbar expose, consolidated into one mobile-friendly editor.
Users
Name
Role
Commits
Name
Role
Commits
Rows per page
1
Code
<DataTable columns={columns} data={users} responsive="stack" enableColumnVisibility />Schema-derived columns
Rather than re-declaring an entity's shape by hand, derive it from a schema you already validate against. columnHelperFor targets the vendor-neutral Standard Schema spec — implemented by Zod v4, Valibot, and ArkType — so the kit depends on the spec, never on any one validator. Type inference only: v1 ships no runtime row validation.
import { z } from "zod";
import { columnHelperFor, DataTable } from "@42/ui-react/data-table";
const UserSchema = z.object({
id: z.string(),
email: z.email(),
age: z.number(),
role: z.enum(["student", "staff"]),
});
const col = columnHelperFor(UserSchema);
// ^ ColumnHelper<{ id: string; email: string; age: number; role: 'student' | 'staff' }>
const columns = [
col.accessor("email", { header: "Email" }),
col.accessor("role", {
header: "Role",
meta: { filter: { type: "select", options: ["student", "staff"] } },
}),
];
// Passing `schema` on DataTable constrains `data` / `columns` to the same inferred type end-to-end.
<DataTable schema={UserSchema} columns={columns} data={rows} />;Controlled state
Every state slice — sorting, filters, globalFilter, pagination, rowSelection,
columnVisibility, columnSizing — takes the same controlled value / onChange shape, so a
plain useState round-trips exactly like the uncontrolled default above. One live proof (sorting
- pagination together, with the raw state printed below) stands in for all seven — the UI is
identical either way, only the wiring changes. See Query integration right
below for the full real-world recipe (server-owned state via
manualSorting/manualFiltering/manualPagination, plus mapping the state into an API's own query params).
| Contributor 1 | Staff | 7 |
| Contributor 2 | Student | 14 |
| Contributor 3 | Student | 21 |
| Contributor 4 | Staff | 28 |
| Contributor 5 | Student | 35 |
Rows per page
1
{
"sorting": [],
"pagination": {
"pageIndex": 0,
"pageSize": 5
}
}Code
function ContributorsTable() { const [sorting, setSorting] = useState([]); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 5 }); return ( <DataTable columns={columns} data={contributors} sorting={sorting} onSortingChange={setSorting} pagination={pagination} onPaginationChange={setPagination} /> );}sorting, filters, or globalFilter through DataTable's own UI — a header click, a filter popover, the search box — always resets pagination's pageIndex back to 0, so a new sort/filter/search never leaves you stranded on a page that no longer has any rows. If one of those three changes from outside DataTable instead — bypassing onSortingChange / onFiltersChange / onGlobalFilterChange entirely — DataTable has no way to notice, and won't reset pagination for you. Reset pageIndex back to 0 yourself, in that same state update.Query integration
The kit ships no query-library adapter — DataTable's controlled state (sorting, filters, globalFilter, pagination, …) is react-table-native on both sides, so a plain useState dispatch is a valid onSortingChange etc. Wiring to TanStack Query is a two-line queryKey + queryFn, no kit-specific glue:
"use client";
import { useState } from "react";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { DataTable } from "@42/ui-react/data-table";
import { columns } from "./columns";
export function UsersTable() {
const [sorting, setSorting] = useState([]);
const [filters, setFilters] = useState([]);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 20 });
const q = useQuery({
queryKey: ["users", { sorting, filters, pagination }], // state drops straight in
queryFn: () => fetchUsers({ sorting, filters, pagination }),
placeholderData: keepPreviousData, // no flash between pages
});
return (
<DataTable
columns={columns}
data={q.data?.rows ?? []}
rowCount={q.data?.total}
loading={q.isLoading}
error={q.isError ? "Failed to load users" : undefined}
manualSorting
manualFiltering
manualPagination
sorting={sorting}
onSortingChange={setSorting}
filters={filters}
onFiltersChange={setFilters}
pagination={pagination}
onPaginationChange={setPagination}
/>
);
}For 42's own SortQuery / FilterQuery / paginated-response contract, map the state into query params once per app:
import { match, P } from "ts-pattern";
type ApiFilter = Record<string, unknown>;
type FilterType = "number" | "date" | "date-range" | "text" | string;
type TableFilter = {
id: string;
value: unknown;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
type NumberFilterValue =
| { op: "eq" | "gt" | "gte" | "lt" | "lte"; value?: number }
| { op: "between"; min?: number; max?: number };
type DateFilterValue =
| { op: "eq" | "gt" | "gte" | "lt" | "lte"; date?: string }
| { op: "between"; from?: string; to?: string };
const hasNumberOp = (value: unknown): value is NumberFilterValue =>
isRecord(value) && typeof value.op === "string";
const hasDateOp = (value: unknown): value is DateFilterValue =>
isRecord(value) && typeof value.op === "string";
const toApiFilter = ({ id, value }: TableFilter, type: FilterType): ApiFilter =>
match({ type, value })
.with({ type: "number", value: P.when(hasNumberOp) }, ({ value: v }) =>
v.op === "between"
? {
...(v.min != null && { [`${id}[gte]`]: v.min }),
...(v.max != null && { [`${id}[lte]`]: v.max }),
}
: v.value != null
? { [`${id}[${v.op}]`]: v.value }
: {},
)
.with({ type: "date", value: P.when(hasDateOp) }, ({ value: v }) =>
v.op === "between"
? {
...(v.from && { [`${id}[gte]`]: v.from }),
...(v.to && { [`${id}[lte]`]: v.to }),
}
: v.date
? { [`${id}[${v.op}]`]: v.date }
: {},
)
.with(
{ type: "date-range", value: P.when(isRecord) },
({ value: { from, to } }) => ({
...(from && { [`${id}[gte]`]: from }),
...(to && { [`${id}[lte]`]: to }),
}),
)
.with({ value: P.array(P.any) }, ({ value }) => ({
[`${id}[in]`]: value.join(","),
}))
.with({ type: "text" }, () => ({
[`${id}[like]`]: value,
}))
.with({ type: P.union("number", "date", "date-range") }, () => ({}))
.otherwise(() => ({
[id]: value,
}));
function toApiQuery(
{
sorting,
filters,
pagination,
}: {
sorting: { id: string; desc: boolean }[];
filters: TableFilter[];
pagination: { pageIndex: number; pageSize: number };
},
filterType: (id: string) => FilterType,
) {
const sort = sorting.map(({ id, desc }) => (desc ? `-${id}` : id)).join(",");
const filter = filters.reduce<ApiFilter>(
(query, filter) =>
Object.assign(query, toApiFilter(filter, filterType(filter.id))),
{},
);
return {
sort,
filter,
page: pagination.pageIndex + 1,
hitsPerPage: pagination.pageSize,
};
}
// queryFn — the response maps straight back onto DataTable props:
const res = await fetchUsers(toApiQuery(state, typeOf));
return { rows: res.hits, total: res.pagination.nbHits };page is 1-based (react-table's pageIndex is 0-based); half-open ranges emit only the defined bound. See the RFC for the full contract, including the in-range-only caveat on date-range filters.
Every example above runs against a small in-memory array. For the same contract wired to a real third-party API — server-driven sorting, filtering, and search (synced to the URL) all round-tripping as genuine network requests, real loading/error/retry, and a row-detail Drawer — see the Magic: The Gathering example.
Subcomponents
Prop
Type
API
Prop
Type
Table
Dumb, styled semantic table on a bordered surface. Pure Server Component with namespaced parts — the primitive DataTable renders through.
Magic: The Gathering example
A full DataTable integration against a real API (scryfall.com) — server-driven sorting, filtering, and global search synced to the URL, a row-detail Drawer, and real loading/error/retry states.