Skip to main content

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.

Loading example

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 with filter (text, select facets, date-range), resize handles (drag, or arrow keys on the handle, within minSize / 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; pass tableOptions={{enableMultiSort: false}} to turn it off). A display column (one with no accessorKey / accessorFn) has nothing to filter by: its filter is 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 emits search, 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 enableInlineEdit shows an input; Enter commits through onCellCommit, Escape drops the draft, a blur commits (commitOnBlur={false} drops it instead). An editable cell owns its clicks: neither click of the double-click reaches onRowClick, which the rest of the row still fires.
  • Expandable rows: renderExpanded adds a chevron column and a full-width row under each open row; expanded lives 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 selectionActions entry and a clear button. Once every row on the page is selected and more rows match, a "Select all N" button sets allMatching in the state: the count reads "All N selected", the actions receive {allMatching: true, rowCount} and toQuery carries allMatching: true for 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 in locale, the user's by default), first / previous / next / last.
  • States: loading renders 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 with skeletonWhileRefreshing); error replaces the rows with a danger-tone alert, or sits above them as a banner when there are rows; an empty page shows emptyState (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.

Loading example

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.