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-central/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 useTable instance and skips DataTable entirely — register dataTableFeatures (exported from @42-central/ui-react/data-table) on it so the instance satisfies the types DataTable.Pagination and friends expect.
Basic
Columns are plain ColumnDef objects — @42-central/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 |
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-central/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 |
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 |
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 |
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 |
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 Period To time Period | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 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 |
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 |
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 |
1
Code
<DataTable
columns={[
{ accessorKey: 'name', header: 'Name' },
{
accessorKey: 'verified',
header: () => <ShieldCheckIcon className="size-4" />,
meta: { label: 'Verified' },
cell: ({ getValue }) => (
<Badge color={getValue() ? 'green' : 'neutral'} size="sm">
{getValue() ? 'Verified' : 'Pending'}
</Badge>
),
},
]}
data={users}
enableColumnVisibility
/>Pagination
DataTable renders a pagination footer automatically — a page-size Select, prev/next, and a current-page indicator, 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).
By default (paginationMode="page") the indicator shows the page number, with the row range and total revealed in a tooltip on hover or focus. paginationMode="rows" mirrors that — the row range and total are always visible, with the page number moved into the tooltip instead — see Always-visible row range below.
| Contributor 1 | Staff | 7 |
| Contributor 2 | Student | 14 |
| Contributor 3 | Student | 21 |
| Contributor 4 | Staff | 28 |
| Contributor 5 | Student | 35 |
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 |
1
Code
<DataTable
columns={columns}
data={/* 24 users */}
pageSizeOptions={[5, 15, 30]}
initialState={{ pagination: { pageIndex: 0, pageSize: 5 } }}
/>Always-visible row range
paginationMode="rows" renders the row range and total inline instead of the page number, with no
hover or focus needed — and announces it via role="status" as the page changes. The page number
isn't lost: it moves into the tooltip, the mirror image of the default. Useful when that count is
information the table exists to answer, not a detail to reveal on request.
| Contributor 1 | Staff | 7 |
| Contributor 2 | Student | 14 |
| Contributor 3 | Student | 21 |
| Contributor 4 | Staff | 28 |
| Contributor 5 | Student | 35 |
1 to 5 of 24
Code
<DataTable
columns={columns}
data={/* 24 users */}
paginationMode="rows"
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 |
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 |
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 |
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, …), on content a cell portals out of the row (its own Menu or Dialog), or ended a
text selection over the row — so it composes safely with row selection, a trailing action column
and copying a cell's value instead of stealing their clicks. Only the row's own content is checked,
so the table can sit inside a Tabs panel or a dialog. 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 |
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.
1
No users match your filters.Some description | |
1
Couldn't load users. Try again. | |
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 |
1
Code
function UsersTable() {
const [loading, setLoading] = useState(false);
const refetch = () => {
setLoading(true);
fetchUsers().then(() => setLoading(false));
};
return (
<>
<Button size="sm" variant="light" color="neutral" 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-central/ui-react/data-table) to match the default bar's shimmer/fill/radius.
1
Code
import { DATA_TABLE_SKELETON_CLASS } from '@42-central/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 |
1
| Ada Lovelace | Staff |
| Alan Turing | Student |
1
| Ada Lovelace | Staff |
| Alan Turing | Student |
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 |
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 |
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 |
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
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-central/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 |
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-central/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.
Translations
translations is the one prop for every piece of wording DataTable renders: the pagination footer, the accessible names of the header controls (sort, filter, resize, select-all, column visibility), the empty and error actions, the number filter's placeholders and the stacked layout's options drawer. English defaults ship in the kit, and a partial override only replaces the keys you set. Keys that name a column take a function, so word order stays the translator's call.
Contributeurs
| Ada | Staff | 128 | |
| Grace | Student | 64 | |
| Alan | Student | 32 |
1
Code
const SORT_MODE_FR = { asc: "croissant", desc: "décroissant", none: "aucun" };
<DataTable
columns={columns}
data={users}
enableRowSelection
enableColumnVisibility
title="Contributeurs"
translations={{
rows: "Lignes par page",
previousPage: "Page précédente",
nextPage: "Page suivante",
items: ({ from, to, hits }) => `${from}–${to} sur ${hits}`,
selectAllRows: "Tout sélectionner",
selectRow: (id) => `Sélectionner la ligne ${id}`,
columnVisibility: "Colonnes affichées",
triggerLabel: "Afficher les options",
sortColumn: (label, mode) => `Trier ${label} (${SORT_MODE_FR[mode]})`,
}}
/>Row, cell & header test ids
Every row, cell, and header gets a data-testid automatically — from the row's resolved id
(getRowId, or react-table's own index-based fallback) and each column's id — with no
configuration required:
<DataTable columns={columns} data={users} />
// → data-testid="row-0", "row-0-cell-email", "header-email", …Override the row scheme with getRowTestId; every cell still derives from whatever it returns.
See the Testing guide for the full convention and a Playwright example against
a DataTable row.
Subcomponents
Prop
Type
API
Prop
Type
Changelog
- #331
translationscovers every label: select-all and row checkboxes, sort, filter and resize names, the options menu, filter placeholders, Retry, and the page-size Select and filters it renders - #317
Calendar, DatePicker, DataTable's date filter and Schedule's event details follow
IntlProvider'stimeZone(prop, then provider, then host) instead of always reading the host zone
- #305
onRowClickfires when the table sits inside an element with arole(a Tabs panel, a dialog), and a click on a cell'srole="img"orprogressbarcontent is no longer dropped - #305
A click inside content a cell opens elsewhere on the page (its own Menu or Dialog), or the click that ends a text selection, no longer fires
onRowClick
- #231
No more blank band under one or two rows
- #182
Move to react-table v9 (peer
9.2.4); the kit re-exports pre-boundColumnDef,RowandcreateColumnHelper
- #161
Header focus rings no longer punch a white halo into the header in light theme
- #146
The "Rows per page" label no longer wraps at narrow footer widths
- #56
paginationMode="rows"keeps the row range always visible and announces it on page change
- #37
Row hover, striped and selected states share one black/white overlay, and header, footer and pagination one gray
- #24
translationsoverrides the built-in English microcopy, including in DataTable's date filters
Date and date-range column filters use DatePicker's popover, and accept
presetsandtimeZone
- #15
Select column filters accept a custom
renderItem
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.