Skip to main content

Data grid API

import DataGrid, {useDataGrid, toQuery, toQueryKey, dateCell} from '@zuilib/data-grid' // everything
import DataGrid, {useDataGrid, toQuery, fromQuery} from '@zuilib/data-grid/data-grid'
import useDataGrid from '@zuilib/data-grid/use-data-grid'
import {toQuery, fromQuery, toQueryKey, toServerState, toColumnState, fromColumnState} from '@zuilib/data-grid/server-adapter'
import {dateCell, dateTimeCell, createDateCell, textColumn, twoValuesColumn} from '@zuilib/data-grid/cells'

Peer dependencies: React and React DOM 18 or 19. TanStack Table and Headless UI install transitively.

DataGrid

Generic in the row type: <DataGrid<Invoice> ... />. Extends the <div> attributes; the rest are:

PropTypeDefaultDescription
columnsrequiredDataGridColumnDef<TData>[]TanStack ColumnDefs plus the ZUI extras
rowsrequiredTData[]The current page
rowCountrequirednumberTotal rows on the server, all pages; drives the page count and the range text. Ignored with manualPagination={false}: the rows that pass the filters are counted instead
statePartial<DataGridState>The controlled state; missing keys take their defaults. Left out, the grid keeps the state itself
defaultStatePartial<DataGridState>The initial state when state is left out; read once
onStateChange(state: DataGridState) => voidCalled with the whole next state on every interaction, controlled or not: the state prop as rendered plus one change. An emission that is not applied is not carried into the next one (see the contract)
getRowId(row, index, parent?) => stringRow identity for selection, expansion and inline edit; defaults to the row index. Required for selection to survive paging: with the default, row 3 of every page is the same key
manualPaginationbooleantrueOff: TanStack pages `rows` itself
manualSortingbooleantrueOff: TanStack sorts `rows` itself
manualFilteringbooleantrueOff: TanStack filters `rows` itself
loadingbooleanfalsearia-busy on the table and "Loading…" in the footer; skeleton rows in the current layout while there are no rows yet
skeletonWhileRefreshingbooleanfalseShow the skeleton rows while loading even when rows are already displayed; off, a refetch keeps the current rows on screen
errorReactNodeWith no rows it replaces the body; with rows it sits above them as a banner. A string is rendered in a danger-tone Alert
labelsPartial<DataGridLabels>Overrides any of the grid's strings: the footer, the checkbox and button names, the filter forms, the chips, the column manager, the empty state. defaultDataGridLabels holds the English ones; templates carry {count}, {column}, {first} / {last} / {total} or {date} placeholders. See Localisation
emptyStateReactNodeReplaces the default "No results" EmptyState
density'compact' | 'comfortable''comfortable'Table size sm / md
stickyHeaderbooleanfalseHeader cells stick to the top of the scroll container; bound it with scrollAreaClassName="max-h-96"
onRowClick(row: Row<TData>, event) => voidClick, Enter or Space on a row. Clicks on an editable cell (enableInlineEdit) stay with the cell: a double-click to edit does not fire it
onCellCommit({rowId, columnId, value}) => voidCommit of an inline edit on a column with enableInlineEdit; value is the raw input string
commitOnBlurbooleantrueAn inline edit that loses focus (a click elsewhere, Tab) is committed; false drops the draft instead
selectAllMatchingbooleantrueOffer "Select all N" in the selection bar once every row on the page is selected and rowCount exceeds the page; sets allMatching in the state
localestringBCP 47 tag for the numbers the grid formats (the range text, the selection counts); the user's locale when left out or when the tag is invalid (with a development warning). Text is labels
renderExpanded(row: Row<TData>) => ReactNodeContent of the full-width row under an open row; adds the chevron column (EXPAND_COLUMN_ID) after the selection column
getRowCanExpand(row: Row<TData>) => booleanWhich rows may open; every row when renderExpanded is set and this is left out
selectionActionsDataGridSelectionAction[][]{id, label, variant?, tone?, onSelect(selectedIds, {allMatching, rowCount})} buttons in the selection bar; with allMatching the action applies to every row matching the query, not only selectedIds
enableRowSelectionboolean | (row) => booleantrueAdds the leading checkbox column
enableColumnResizingbooleantrue
pageSizeOptionsnumber[][10, 25, 50, 100]A current page size outside the list is shown alongside it
showPaginationbooleantrueRender the footer; false to place DataGrid.Pagination yourself
tableOptionsPartial<TableOptions<TData>>Anything else for useReactTable (defaultColumn, enableMultiSort, ...). Multi-sort is on by default: Shift-click on a head adds that column to sorting; {enableMultiSort: false} makes every click replace it
aria-labelstring'Data grid'Accessible name of the role="grid" table (or pass aria-labelledby)
childrenReactNodeRendered above the selection bar and the table: DataGrid.Toolbar, headings, actions
classNamestringMerged last onto the root
tableClassNamestringMerged last onto the <table>
scrollAreaClassNamestringMerged last onto the scrolling wrapper

DataGridState

All keys are TanStack's state shapes. defaultDataGridState holds the defaults; resolveDataGridState(partial) fills a partial.

PropTypeDefaultDescription
pagination{pageIndex: number; pageSize: number}{pageIndex: 0, pageSize: 25}0-based page
sorting{id: string; desc: boolean}[][]
columnFilters{id: string; value: unknown}[][]A string (text), a string[] (select) or {from?, to?} (date-range) per column
searchstring''The toolbar search
columnVisibilityRecord<string, boolean>{}
columnOrderstring[][]Leaf column ids. The selection and expand columns (SELECT_COLUMN_ID, EXPAND_COLUMN_ID) are moved first whatever order comes in
columnPinning{start?: string[]; end?: string[]}{start: [], end: []}The selection and expand columns are always first in start; the state the grid emits includes them
rowSelectionRecord<string, boolean>{}Keyed by getRowId
expandedRecord<string, boolean> | true{}Open rows, keyed by getRowId (TanStack ExpandedState); UI-only, not part of toQuery
allMatchingbooleanfalse"Select all N": every row matching the query is selected, beyond this page. Set by the selection bar, cleared by any other selection change and by a sort / filter / search change; part of toQuery
columnSizingRecord<string, number>Column widths in px, keyed by column id. Optional: left out, the grid keeps the widths itself; passed (even {}), every resize goes out through onStateChange so it can be persisted. UI-only, not part of toQuery

A sort, filter or search change also resets pagination.pageIndex to 0.

Column extras

DataGridColumnDef<TData> is TanStack's ColumnDef plus these (also accepted on meta, TanStack's own extension point). enableSorting, enableHiding, enableResizing, enableColumnFilter, size, minSize and maxSize work as in TanStack.

PropTypeDefaultDescription
enableInlineEditbooleanDouble-click / Enter on the cell shows an input (the cell carries aria-readonly="false"); Enter commits, Escape drops the draft, a blur follows commitOnBlur
align'start' | 'center' | 'end''start'Head and cell alignment
widthnumberInitial width in px (TanStack size); the user resizes from there
filterDataGridFilterDeclares the head's filter popover: {control: 'text', placeholder?}, {control: 'select', options: [{value, label?}]} or {control: 'date-range'}. Ignored (with a one-time console warning in development) on a display column, one with no accessorKey / accessorFn
meta.labelstringOnly on meta: the column's name in the column manager, the filter chips and the pin / resize labels when header is not a string (a component, a function); otherwise the string header, then the id

Cells and column helpers

From @zuilib/data-grid/cells. The renderers go in a column's cell; the factories return a DataGridColumnDef and pass every other column option (width, align, filter, enableHiding, ...) through.

PropTypeDescription
dateCell(ctx: CellContext) => ReactNodeThe date in the user's locale (dateStyle: 'medium') in a <time dateTime>; - for an empty or invalid value
dateTimeCell(ctx: CellContext) => ReactNodeDate and time (dateStyle: 'medium', timeStyle: 'short')
createDateCell({locale?, format?, empty?}) => rendererYour own preset: a BCP 47 locale, Intl.DateTimeFormatOptions and the empty placeholder
textColumn({field, header, enableSorting?, cell?, ...column}) => DataGridColumnDefOne field as text, sortable by default; field is the accessor and the column id
twoValuesColumn({header, primary, secondary, id?, enableSorting?, ...column}) => DataGridColumnDefprimary / secondary are {field, render?}: the first line in medium weight, the second small and muted; the accessor is primary.field and the column id is id ?? primary.field, so a sort or filter entry in the state carries that id

Parts

Every part reads the grid from context and must sit inside <DataGrid>.

DataGrid.Toolbar

PropTypeDefaultDescription
showSearchbooleantrueThe search field
searchPlaceholderstringlabels.searchPlaceholder'Search…' by default
searchDebounceMsnumber300ms after the last keystroke before search is emitted; Enter emits at once
showFilterChipsbooleantrueA removable chip per active column filter
showColumnManagerbooleantrueThe column manager button, right-aligned
addableColumns{id, label}[]Fields the column manager offers under an “Add column” section — see DataGrid.ColumnManager
onAddColumn(id: string) => voidCalled with a picked addable field’s id; you append the column def
childrenReactNodeRendered between the chips and the column manager

DataGrid.ColumnManager

A popover button listing every column with a visibility checkbox, up / down and pin start / end buttons. label (default labels.columns, 'Columns') names the button.

addable (an array of DataGridAddableField, {id, label}) with onAddColumn adds an Add column section under the list: fields your data carries that the grid has no column for yet. The grid can't invent a TanStack column definition, so picking one only calls onAddColumn(id) — you append the def to columns (and persist it however you persist the rest of the layout). The @zuilib/apps data-grid preset wires this to the dataset schema, so spec-driven grids get add-from-data for free.

DataGrid.SelectionBar

Renders nothing until a row is selected. No props beyond the <div> attributes; reads selectionActions, selectAllMatching, labels and locale from the grid. "Select all N" needs setState in the context (DataGrid provides it).

DataGrid.Body

The <table> alone. Takes stickyHeader, onRowClick, error, skeletonWhileRefreshing, emptyState, className, scrollAreaClassName and the aria-label / aria-labelledby of the root.

DataGrid.Pagination

The footer alone; a <nav> named labels.pagination. Takes locale to override the grid's.

useDataGrid

const {table, state, setState, replaceState} = useDataGrid<Invoice>({
columns, rows, rowCount, state, defaultState, onStateChange, fixedColumnIds,
manualPagination, manualSorting, manualFiltering,
getRowId, enableRowSelection, enableColumnResizing, tableOptions,
})

getRowCanExpand is accepted too. table is the TanStack instance with every on*Change wired to onStateChange (expansion included). Without state the hook keeps the state itself from defaultState. fixedColumnIds are the ids kept first in columnOrder and among the start-pinned columns (DataGrid passes its selection and expand columns; normalizeColumnState(state, ids) is the function it uses). setState(key, updater) replaces one key and emits; replaceState(partial) emits a merged state (a reset, a restore). To render the parts yourself, provide a DataGridContext value of {table, state, setState?, loading, density, onCellCommit, renderExpanded, selectionActions, pageSizeOptions, rowCount, labels, locale?, commitOnBlur, selectAllMatching} (labels is a full DataGridLabels; spread defaultDataGridLabels. rowCount is what the footer and aria-rowcount show: table.getRowCount() gives the prop, or the filtered count under client-side paging). Wrap the parts in an element with @container: the toolbar and footer respond to that element's width, the way they respond to the DataGrid root.

Server adapter

PropTypeDescription
toQuery(state: Partial<DataGridState>) => DataGridQuery{page, pageSize, sort: [{id, desc}], filters, search, allMatching}; page is 1-based, empty filter values are dropped and filters is keyed in column id order; selection, visibility, order, pinning, sizing and expansion are left out. Pure: depends only on toServerState(state)
toQueryKey(state: Partial<DataGridState>) => stringtoQuery as a stable string: equal for two states whose request would be the same, whatever order their filters were applied in. The dependency for the fetching effect or the cache key
toServerState(state: Partial<DataGridState>) => DataGridServerStateThe pagination, sorting, columnFilters, search and allMatching of a state
fromQuery(query: Partial<DataGridQuery>) => DataGridServerStateThe state a query describes; the input is untrusted and it never throws. page / pageSize must be positive integers (else page 1 / the default size); a sort entry without a string id, a non-object filters or a non-string search is ignored; ids and filter values are not validated against the columns
toColumnState(state: Partial<DataGridState>) => DataGridColumnStatecolumnVisibility, columnOrder, columnPinning, columnSizing for a saved view, without the grid's own selection / expand columns
fromColumnState(saved: Partial<DataGridColumnState>) => DataGridColumnStateThe same keys with the defaults filled in; spread over the state to restore a view

Accessibility

  • A real <table role="grid"> with aria-rowcount (all rows on the server plus every header row), aria-rowindex on every row (header rows, data rows, the error and empty rows), aria-colcount and aria-colindex on every head and cell, and aria-busy while loading. The column counts cover the visible leaf columns: a column hidden through the column manager is removed from the grid rather than left as a gap, so the visible set is the set the grid presents.
  • Sortable heads carry aria-sort; the sort control is a button, the filter control a sibling popover button (never nested). Both stay in the tab sequence. The head cell itself is reached with ArrowUp from the first row; it then holds the grid's roving tab stop, and only that head's resize handle (role="separator" with aria-valuenow / aria-valuemin / aria-valuemax) is a tab stop.
  • The grid is one tab stop (roving tabindex: the row, cell or head last focused keeps it, the first row until then). Row checkboxes, expand toggles and editable content are tabindex="-1": Enter / Space on their cell operates them, Escape inside one returns to the cell. On a row, ArrowUp / ArrowDown / Home / End move between rows and Enter / Space activate onRowClick; ArrowRight steps into the cells and the arrows, Home / End (Ctrl for first / last row) move between them. Shift+ArrowRight / Shift+ArrowLeft open / close an expandable row. Rows carry aria-selected; editable cells carry aria-readonly="false".
  • The expand toggle is a named button with aria-expanded.
  • The head checkbox is tri-state (aria-checked="mixed"); every checkbox, icon button and resize handle has a name, all of them from labels.
  • Pinned columns stick with logical insets and the resize handle sits at the inline end, so dir="rtl" mirrors the grid.
  • The selection count and the range text are aria-live="polite".

Slots

SlotElementNotes
[data-slot="data-grid"]divRoot; data-density, data-loading
[data-slot="data-grid-toolbar"]div
[data-slot="data-grid-search"]inputThe search (a SearchInput)
[data-slot="data-grid-filter-chips"]ul
[data-slot="data-grid-filter-chip"]spanA Badge; data-column
[data-slot="data-grid-column-manager"]divThe popover root
[data-slot="data-grid-column-manager-button"]button
[data-slot="data-grid-column-manager-panel"]div
[data-slot="data-grid-column-manager-row"]lidata-column
[data-slot="data-grid-selection-bar"]divOnly while rows are selected; data-all-matching
[data-slot="data-grid-selection-count"]span
[data-slot="data-grid-selection-select-all"]buttonThe "Select all N" button; only while the page is selected and more rows match
[data-slot="data-grid-selection-action"]buttondata-action = the action id
[data-slot="data-grid-selection-clear"]button
[data-slot="data-grid-table"]tablerole="grid"; data-density, data-loading
[data-slot="data-grid-header"]thead
[data-slot="data-grid-header-row"]traria-rowindex; data-leaf on the row of leaf heads
[data-slot="data-grid-header-cell"]thdata-column, data-sort, data-pinned, aria-sort, aria-colindex
[data-slot="data-grid-sort-button"]buttondata-sort
[data-slot="data-grid-sort-icon"]svg
[data-slot="data-grid-header-label"]spanAn unsortable head
[data-slot="data-grid-filter"]divThe filter popover root
[data-slot="data-grid-filter-button"]buttondata-active while a filter is set
[data-slot="data-grid-filter-panel"]div
[data-slot="data-grid-filter-form"]form
[data-slot="data-grid-resize-handle"]divrole="separator"
[data-slot="data-grid-body"]tbody
[data-slot="data-grid-row"]trdata-row-id, data-selected, data-expanded, aria-selected, aria-rowindex
[data-slot="data-grid-cell"]tdrole="gridcell"; data-column, data-pinned, aria-colindex; data-editable, data-editing, aria-readonly on an editable cell
[data-slot="data-grid-cell-content"]divThe content of an editable cell at rest; data-editable
[data-slot="data-grid-cell-editor"]divThe Input while editing
[data-slot="data-grid-select-all"]spanThe head Checkbox
[data-slot="data-grid-select-row"]spanA row Checkbox
[data-slot="data-grid-expand-toggle"]buttonThe chevron; aria-expanded, data-expanded
[data-slot="data-grid-expanded-row"]trThe full-width row under an open row; data-row-id
[data-slot="data-grid-expanded-cell"]tdSpans every column; holds renderExpanded
[data-slot="data-grid-date-cell"]time | spandateCell / dateTimeCell; a span with data-empty when there is no value
[data-slot="data-grid-text-cell"]spantextColumn default cell
[data-slot="data-grid-two-values"]divtwoValuesColumn stack, with -primary and -secondary spans
[data-slot="data-grid-skeleton-row"]tr
[data-slot="data-grid-message-row"]trThe row holding the error or the empty state; aria-rowindex
[data-slot="data-grid-error"]td
[data-slot="data-grid-empty"]td
[data-slot="data-grid-pagination"]nav
[data-slot="data-grid-page-size"]label
[data-slot="data-grid-range"]p1–25 of 1,240
[data-slot="data-grid-page-buttons"]div
[data-slot="data-grid-page-indicator"]span