Data grid
Package: @zuilib/data-grid · 0.4.0
A data grid for lists that live on a server: sorting, filtering, search,
pagination and selection are all controlled. The grid renders the
rows and rowCount you give it and hands every interaction back as one
state object; you fetch, and pass the result in. Nothing is sorted or
filtered on the client unless you ask for it. Leave state out and the
grid keeps it itself (from defaultState), still reporting every change.
It is built on TanStack Table for the column
model and on the @zuilib/primitives primitives (Table, Checkbox,
Input, Select, Popover, Badge, Button, Skeleton, EmptyState,
Alert) for everything visible, so it reads the same tokens as the rest of
ZUI. A theme applies to it with no grid-specific overrides.
Everything above runs against a fake server: 200 generated rows, sorted,
filtered and paged in memory after a short delay. Click a head to sort, the
funnel to filter, type in the search, tick rows for the selection bar,
double-click a customer or an amount to edit it inline, and page through
the footer. Skeleton rows stand in for the first page while it loads; a
later request keeps the current rows on screen (with aria-busy on the
table) unless skeletonWhileRefreshing is set.
Install
pnpm add @zuilib/data-grid
React and React DOM are the required peers. TanStack Table, primitives, Headless UI and tokens install transitively. Load the component and grid Tailwind entries once; see the components getting started.
@import "tailwindcss";
@import "@zuilib/primitives/tailwind.css";
@import "@zuilib/data-grid/tailwind.css";
Quick start
Own the state, fetch when the request changes, pass the page in. toQuery
turns the grid state into the request shape most list endpoints take;
toQueryKey is a string that changes only when that request would, so a
checkbox, a hidden column, a pin or a resize never refetches.
import {useEffect, useState} from 'react'
import DataGrid, {
type DataGridColumnDef,
type DataGridState,
defaultDataGridState,
toQuery,
toQueryKey,
} from '@zuilib/data-grid'
type Invoice = {id: string; customer: string; status: string; amount: number; issued: string}
const columns: DataGridColumnDef<Invoice>[] = [
{accessorKey: 'id', header: 'Invoice', width: 130, filter: {control: 'text'}},
{accessorKey: 'customer', header: 'Customer', enableInlineEdit: true},
{
accessorKey: 'status',
header: 'Status',
filter: {control: 'select', options: [{value: 'Paid'}, {value: 'Pending'}, {value: 'Overdue'}]},
},
{accessorKey: 'issued', header: 'Issued', filter: {control: 'date-range'}},
{accessorKey: 'amount', header: 'Amount', align: 'end', cell: ({getValue}) => `$${getValue()}`},
]
// Your API client. Resolves with the page and the total across all pages.
// `sort` and `filters` are structured, so they travel as JSON parameters.
async function fetchInvoices(query: ReturnType<typeof toQuery>) {
const params = new URLSearchParams({
page: String(query.page),
pageSize: String(query.pageSize),
sort: JSON.stringify(query.sort),
filters: JSON.stringify(query.filters),
search: query.search,
})
const response = await fetch(`/api/invoices?${params}`)
return (await response.json()) as {rows: Invoice[]; total: number}
}
export function Invoices() {
const [state, setState] = useState<DataGridState>({
...defaultDataGridState,
pagination: {pageIndex: 0, pageSize: 25},
})
const [page, setPage] = useState({rows: [] as Invoice[], total: 0})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string>()
// Only pagination, sorting, columnFilters, search and allMatching are in the key.
const key = toQueryKey(state)
useEffect(() => {
let cancelled = false
setLoading(true)
fetchInvoices(toQuery(state))
.then((result) => !cancelled && (setPage(result), setError(undefined)))
.catch(() => !cancelled && setError('Could not load invoices.'))
.finally(() => !cancelled && setLoading(false))
return () => {
cancelled = true
}
}, [key]) // eslint-disable-line react-hooks/exhaustive-deps
return (
<DataGrid
columns={columns}
rows={page.rows}
rowCount={page.total}
getRowId={(row) => row.id}
state={state}
onStateChange={setState}
loading={loading}
error={error}
onCellCommit={({rowId, columnId, value}) => saveField(rowId, columnId, value)}
selectionActions={[{id: 'export', label: 'Export', onSelect: (ids) => exportInvoices(ids)}]}
aria-label="Invoices">
<DataGrid.Toolbar searchPlaceholder="Search invoices…" />
</DataGrid>
)
}
Sorting, a filter and a search all reset pageIndex to 0: a new
ordering makes the old page meaningless. Selection is keyed by getRowId,
so it survives paging (ids from another page stay selected until cleared);
without it rows are keyed by index, and row 3 of every page shares one
selection.
With a query cache (TanStack Query, SWR) use toQueryKey(state) as the key
and toQuery(state) in the fetcher; toServerState(state) picks the same
keys as an object when you would rather compare them yourself.
The onStateChange contract
Every emission is the state prop as currently rendered plus one change,
the way a controlled input works. Apply it (setState(next)) and the
next interaction builds on it; ignore it (if (!valid) return) and it is
rejected: nothing of it is carried into the next emission. Apply
emissions synchronously, then fetch, as the example above does. A
consumer that waits for the server before applying will see a second
quick interaction derived from the old prop, not from the one still in
flight.
What is in the box
- Header: sortable heads (
aria-sort), a filter popover per column declared withfilter(text,selectfacets,date-range), resize handles (drag, or arrow keys on the handle, withinminSize/maxSize), pinned columns that stick to the start or end. Shift-click adds a column to the sort (TanStack's multi-sort, on by default; passtableOptions={{enableMultiSort: false}}to turn it off). A display column (one with noaccessorKey/accessorFn) has nothing to filter by: itsfilteris ignored, no funnel is shown, and development logs a warning once. The selection and expand columns are always first and always pinned to the start, ahead of any column you pin. - Toolbar (
DataGrid.Toolbar): a debounced search that emitssearch, a removable chip per active column filter, the column manager (visibility, up / down reorder, pin start / end) and your own children. - Rows and cells: a tri-state selection checkbox in the head and one
per row; the body is one tab stop (the row, cell or head last focused
keeps it). On a row, ArrowUp / ArrowDown / Home / End move between rows and
Enter / Space fire
onRowClick; ArrowRight steps into the cells, where the arrows move between cells (ArrowLeft from the first cell goes back to the row, ArrowUp from the first row reaches the header) and Enter / Space act on the cell's checkbox or chevron, start an inline edit, or click the row. The controls inside cells are not tab stops. - Inline edit: double-click, or Enter, on a cell of a column with
enableInlineEditshows an input; Enter commits throughonCellCommit, Escape drops the draft, a blur commits (commitOnBlur={false}drops it instead). An editable cell owns its clicks: neither click of the double-click reachesonRowClick, which the rest of the row still fires. - Expandable rows:
renderExpandedadds a chevron column and a full-width row under each open row;expandedlives in the state. - Cells and column helpers (
@zuilib/data-grid/cells):dateCell,dateTimeCell,textColumn,twoValuesColumn. - Selection bar: appears above the table while rows are selected, with
the count, one button per
selectionActionsentry and a clear button. Once every row on the page is selected and more rows match, a "Select all N" button setsallMatchingin the state: the count reads "All N selected", the actions receive{allMatching: true, rowCount}andtoQuerycarriesallMatching: truefor the server to act on. Any narrower selection or a query change clears it. - Footer: rows per page (
10 / 25 / 50 / 100; a size restored from elsewhere is added to the list),1–25 of 1,240(formatted inlocale, the user's by default), first / previous / next / last. - States:
loadingrenders skeleton rows in the current layout when there are no rows yet, and keeps the current rows while a refetch is in flight (skeletons again withskeletonWhileRefreshing);errorreplaces the rows with a danger-tone alert, or sits above them as a banner when there are rows; an empty page showsemptyState(or the default "No results").
Server adapter
toQuery(state) returns a plain object for your request; fromQuery
is its inverse, for restoring a grid from the URL or a saved view. Both
ignore the client-only keys (selection, visibility, order, pinning,
sizing, expansion). toQueryKey(state) is toQuery as a string and
toServerState(state) the keys it reads, for effect dependencies and cache
keys.
import {fromQuery, toQuery, toQueryKey} from '@zuilib/data-grid/server-adapter'
toQuery(state)
// {
// page: 3, // 1-based
// pageSize: 50,
// sort: [{id: 'amount', desc: true}],
// filters: {status: ['Paid'], issued: {from: '2026-01-01'}},
// search: 'acme',
// allMatching: false, // the selection bar's "Select all N"
// }
setState((s) => ({...s, ...fromQuery(parsedSearchParams)}))
Empty filter values (an empty string or array) are dropped from
filters, which is keyed in column id order so toQueryKey is the same
whatever order the filters were applied in. fromQuery treats its input
as untrusted and never throws: page and pageSize count only as
positive integers (anything else falls back to page 1 and the default
size), sort entries without a string id are dropped, filters must be
a plain object and search a string. Sort ids and filter values are otherwise
passed through as they are: it does not know your columns, so validate
ids that come from a URL yourself.
Saved views
toColumnState(state) picks the column layout (visibility, order, pinning,
sizing) without the grid's own selection and expand columns;
fromColumnState(saved) fills the defaults back in. Spread the result over
the state to restore a view.
import {fromColumnState, toColumnState} from '@zuilib/data-grid/server-adapter'
saveView(name, toColumnState(state))
setState((s) => ({...s, ...fromColumnState(loadView(name))}))
The grid normalises columnOrder and columnPinning on the way in, so a
saved order that lacks (or misplaces) the selection column still renders
it first.
Composing the parts
DataGrid lays out children, the selection bar, the table and the pagination
in a column. For another layout, call useDataGrid yourself and render
the parts inside a DataGridContext; or keep DataGrid and pass
showPagination={false} to render DataGrid.Pagination elsewhere in its
children. Every part reads the table from context and throws outside it.
Parts rendered outside DataGrid respond to the width of the nearest
@container element, so give them one (see
Small screens and touch).
<DataGrid {...props} showPagination={false}>
<header className="flex items-center justify-between">
<h2>Invoices</h2>
<DataGrid.Pagination />
</header>
<DataGrid.Toolbar showSearch={false} showFilterChips={false}>
<Button size="sm">New invoice</Button>
</DataGrid.Toolbar>
</DataGrid>
Expandable rows
Pass renderExpanded and every row gets a chevron in a column inserted
after the selection checkbox. An open row renders your content in a
full-width row under it (data-slot="data-grid-expanded-row"). Narrow
which rows may open with getRowCanExpand; a row that cannot has no
chevron. Keyboard: Shift+ArrowRight / Shift+ArrowLeft on the row or any of
its cells, Enter or Space on the chevron's cell.
Which rows are open is expanded in the state ({[rowId]: true}, keyed
by getRowId), emitted through onStateChange like every other key, so
you can open a row on load or keep it open across a refetch. Expansion is
a view concern: toQuery leaves it out of the request.
<DataGrid
{...props}
renderExpanded={(row) => <OrderLines orderId={row.original.id} />}
getRowCanExpand={(row) => row.original.lineCount > 0}
/>
Cells and column helpers
@zuilib/data-grid/cells holds the cell renderers and column factories,
typed against DataGridColumnDef. They add
no colours of their own: the muted line reads --muted-foreground, the
rest inherits the cell.
import {dateCell, dateTimeCell, createDateCell, textColumn, twoValuesColumn} from '@zuilib/data-grid/cells'
const columns = [
textColumn({field: 'id', header: 'Invoice', width: 130, filter: {control: 'text'}}),
twoValuesColumn({header: 'Customer', primary: {field: 'customer'}, secondary: {field: 'email'}}),
{accessorKey: 'issued', header: 'Issued', cell: dateCell},
{accessorKey: 'updatedAt', header: 'Updated', cell: dateTimeCell},
{accessorKey: 'due', header: 'Due', cell: createDateCell({format: {dateStyle: 'long'}, empty: 'Open')},
]
dateCell and dateTimeCell format through Intl.DateTimeFormat in the
user's locale and render a <time dateTime>; an empty or invalid value
shows -. See the API for the options.
Client-side data
The three manual* options default to true. For a small list already
in memory, turn them off and TanStack sorts, filters and pages the rows
you pass. With manualPagination={false} the grid counts the rows that
pass the filters itself, for the footer and aria-rowcount; the
rowCount prop is ignored (pass allRows.length to satisfy the type).
With manualFiltering={false} each column's filter declaration also
picks its filterFn, so the popover values match the way the server
would: text is a case-insensitive substring match (includesString),
select keeps a row whose value is in the picked list (arrIncludesSome),
and date-range keeps a row whose ISO date (or Date) falls within the
{from, to} bounds, compared by day, either side optional. A column's
own filterFn wins.
<DataGrid
columns={columns}
rows={allRows}
rowCount={allRows.length}
manualPagination={false}
manualSorting={false}
manualFiltering={false}
state={state}
onStateChange={setState}
/>
Localisation
labels overrides any of the grid's strings; defaultDataGridLabels
lists them all with their English defaults, and a partial override keeps
the rest. Templates take placeholders: {count} in the selection bar,
{column} in the control names, {first} / {last} / {total} in the
range text, {date} in a date-range chip. locale formats the numbers
(an invalid tag falls back to the user's locale with a development
warning); the cell helpers take their own locale.
<DataGrid
{...props}
locale="de-DE"
labels={{
rowsPerPage: 'Zeilen pro Seite',
range: '{first} bis {last} von {total}',
selectRow: 'Zeile auswählen',
filterColumn: '{column} filtern',
noResults: 'Keine Ergebnisse',
}}
/>
Small screens and touch
The grid root is a CSS container and the table scrolls horizontally inside
its own wrapper, so <DataGrid> fits any column it is placed in and never
widens the page; pinned columns and stickyHeader work inside that
scroller. The parts respond to the grid's width, not the viewport: below
40rem the toolbar search is full width, the Columns button shows only its
icon, and the footer is one row (page size, range, previous / next).
On touch screens the row checkboxes, expand chevrons, funnel and sort
triggers and page buttons reach 44px, resize handles are visible, and the
inline edit, filter and page-size inputs render at 16px; desktop keeps the
density's geometry. When composing the parts with useDataGrid, wrap them
in an element with @container to get the same responsive behaviour.
Theming
The grid has no colours or sizes of its own. Heads, cells, rules and
densities come from the Table primitive (--table-cell-padding-*,
--border, --muted-foreground), the controls from their own components,
the selected row from --accent, the pinned column shadow from
--border. Every part carries a data-slot (see the
API) for targeted overrides.