DataTable
A batteries-included data grid — sorting, filtering, pagination, selection, column pinning, virtualization, master-detail rows, and a pre-built cell library — composed from the dumb Table primitives.
Overview
DataTable is the composition-layer component that turns the Table primitives into a working data grid. It owns all model state (sort/filter/pagination/selection/column visibility/pinning/order/detail-panel expansion) via the same controlled/uncontrolled shape every model in this system shares: xModel / defaultXModel / onXModelChange.
Renders a real <table> by default — even when row-virtualized (virtualized), it stays a real <table> via spacer <tr>s around the rendered slice, rather than switching to div-based markup. This keeps native <table> semantics (accessibility, copy-paste-into-spreadsheet) intact for the common case.
Import
import { DataTable } from '@unflowio/ui/components/DataTable';Core props
| Prop | Type | Default | Description |
|---|---|---|---|
columns | ColumnDef<Row>[] | — | Required. |
rows | Row[] | — | Required. |
getRowId | (row: Row) => RowId | row.id | Should be memoized. |
sortModel / defaultSortModel / onSortModelChange | SortItem[] | [] | Single-column 3-state cycle (asc → desc → none) by clicking a header. |
filterModel / defaultFilterModel / onFilterModelChange | FilterModel | { items: [] } | { items, logicOperator?, quickFilterValues? }. Built-in operators: contains/equals/isEmpty/isNotEmpty. |
paginationModel / defaultPaginationModel / onPaginationModelChange | PaginationModel | undefined | undefined (unpaginated) | { page, pageSize }, 0-based page. |
totalRows | number | 'unknown' | { estimate: number } | filtered row count | Drives the pagination footer's item count. |
pageSizeOptions | number[] | [10, 25, 50, 100] | |
checkboxSelection | boolean | false | Adds a leading select-all/select-row checkbox column. |
selectionModel / defaultSelectionModel / onSelectionModelChange | Set<RowId> | new Set() | No selection UI renders unless this or checkboxSelection is set. |
isRowSelectable | (row: Row) => boolean | — | |
columnVisibilityModel / defaultColumnVisibilityModel / onColumnVisibilityModelChange | Record<string, boolean> | {} (all visible) | |
pinnedColumnsModel / defaultPinnedColumnsModel / onPinnedColumnsModelChange | { left?: string[]; right?: string[] } | {} | Pinned columns should set an explicit width for pixel-accurate sticky offsets. |
columnOrder / defaultColumnOrder / onColumnOrderChange | string[] | columns' own order | |
columnResizable | boolean | false | Renders a drag handle on every resizable !== false column's header cell. |
columnWidthsModel / defaultColumnWidthsModel / onColumnWidthsModelChange | Record<string, number> | {} | Per-field width (px) override from a resize — takes priority over that column's own static width. |
columnActions | boolean | false | Adds a per-column edit/pin/duplicate/delete menu (column.disableColumnActions opts one out) and, with columnTypeOptions, a trailing "+" add-column trigger in the table body. |
columnTypeOptions | ColumnTypeOption[] | — | { type, label, icon? }[] offered in the "+" trigger's type picker. Omit to hide that trigger. |
onAddColumn / onEditColumn / onChangeColumnType / onDuplicateColumn / onDeleteColumn | see below | — | DataTable doesn't construct or own columns — each is a pure report for you to act on. |
showToolbar | boolean | false | Mounts the default toolbar (quick filter + columns/filter panel triggers). |
slots.toolbar | ComponentType<DataTableToolbarSlotProps<Row>> | — | Full custom toolbar replacement. |
inlineFilters | boolean | false | Replaces the Filter Panel trigger with one QuickFilterField per filterType-tagged column. See Inline filters. |
virtualized / rowHeight / height / overscanRowCount | — | false / density-derived / — / 5 | Fixed-row-height windowing of a real <table>. height is required for virtualized to have a viewport to window against. |
tabThroughCells / tabThroughHeader | boolean | false | Roving-tabindex + arrow-key navigation (doc's two-boolean simplification of a 4-value tab-navigation enum). |
getDetailPanelContent | (row: Row) => ReactNode | — | Adds an expand/collapse toggle column + a full-width detail row. Works combined with virtualized — see Master-detail rows. |
detailPanelExpandedRowIds / defaultDetailPanelExpandedRowIds / onDetailPanelExpandedRowIdsChange | Set<RowId> | new Set() | |
estimatedDetailPanelHeight | number | 160 | Assumed detail-panel height, only while virtualized, until the panel is actually rendered and measured once. |
rowReordering | boolean | false | Adds a leading drag-handle column. Disabled (handles hidden) while any sortModel entry is active — only meaningful on the "natural" order. |
isRowReorderable | (row: Row) => boolean | — | Disables the handle for specific rows. |
onRowOrderChange | (rows: Row[]) => void | — | DataTable doesn't own rows — the full reordered array is handed back for you to store. |
density | 'compact' | 'standard' | 'comfortable' | 'standard' | |
getRowClassName | (row: Row) => string | undefined | — | |
ref | Ref<DataTableHandle> | — | { scrollToIndexes, scroll, focusCell } — see below. |
ColumnDef<Row>
| Field | Type | Description |
|---|---|---|
field | string | Required, unique. |
headerName | string | |
description | string | Shown as a header tooltip. |
width / minWidth / maxWidth | number | Fixed inline sizing. |
accessor | (row: Row) => unknown | Cheap — feeds sort/filter and the default render path. Defaults to row[field]. For the pre-built cell library, return the cell's expected value shape here (see cell library). |
format | (value, row) => string | Cheap, display-only — never seen by sort/filter. |
cell | ComponentType<CellRenderProps<Row>> | Expensive, opt-in — a component (hooks always safe inside it). |
sortComparator | (a, b, direction) => number | Always receives direction explicitly — never auto-flipped. |
filterFn | (value, item, row) => boolean | Overrides the built-in operators. |
filterType | 'select' | 'range' | 'date' | Renders this column as an inline QuickFilterField when DataTable's inlineFilters is set — see Inline filters. |
filterIcon | ReactNode | Icon shown on this column's inline filter trigger button. |
filterOptions | { value, label, icon?, render? }[] | filterType: 'select''s checkbox list. render replaces the default icon+label row (e.g. a colored Tag matching the column's own cell). |
filterUnit | string | filterType: 'range''s input prefix (e.g. '$'). |
filterRangeBounds | { min, max } | filterType: 'range''s slider domain. A dual-handle slider only renders once this is set. |
filterCurrencyOptions | { value, label }[] | filterType: 'range' — an optional currency/unit-code select (e.g. "US") shown on both numeric inputs, for a currency amount range. |
filterDateShortcuts | { label, value }[] | filterType: 'date''s quick-pick presets. |
userSortable / userFilterable | boolean | UI-only — still programmatically sortable/filterable via a controlled model even when false. |
resizable | boolean | UI-only — still programmatically resizable via a controlled columnWidthsModel even when false. Only relevant when columnResizable is set. |
disableColumnActions | boolean | Hides the edit/duplicate/delete menu for this one column even when columnActions is set (e.g. a required/system column). |
align | 'left' | 'center' | 'right' | |
colSpan | number | ((row: Row) => number | undefined) | Per-row column merging. |
Basic
Ava Carter | ava.carter@example.com | Engineering |
|---|---|---|
Liam Nguyen | liam.nguyen@example.com | Design |
Noah Silva | noah.silva@example.com | Sales |
Emma Kim | emma.kim@example.com | Marketing |
Mateo Costa | mateo.costa@example.com | Support |
Using the cell library
Columns opt into the pre-built cell library by returning a shaped value from accessor and pointing cell at the matching CellX component:
Ava Carter | $128,000.00 | Remote Team lead | |
|---|---|---|---|
Liam Nguyen | $98,000.00 | On-site | |
Noah Silva | $87,000.00 | Contractor | |
Emma Kim | $94,500.00 | Remote | |
Mateo Costa | $71,000.00 | On-site Team lead |
Pagination
Unpaginated unless a model is passed:
Ava Carter | ava.carter@example.com | Engineering |
|---|---|---|
Liam Nguyen | liam.nguyen@example.com | Design |
Noah Silva | noah.silva@example.com | Sales |
Selection
Ava Carter | ava.carter@example.com | Engineering | |
|---|---|---|---|
Liam Nguyen | liam.nguyen@example.com | Design | |
Noah Silva | noah.silva@example.com | Sales | |
Emma Kim | emma.kim@example.com | Marketing | |
Mateo Costa | mateo.costa@example.com | Support |
Toolbar (quick filter, columns panel, filter panel)
Ava Carter | $128,000.00 | Remote Team lead | ||
|---|---|---|---|---|
Liam Nguyen | $98,000.00 | On-site | ||
Noah Silva | $87,000.00 | Contractor | ||
Emma Kim | $94,500.00 | Remote | ||
Mateo Costa | $71,000.00 | On-site Team lead |
See Toolbar & Panels for building a fully custom toolbar out of the same composable parts.
Inline filters
inlineFilters swaps the single Filter Panel trigger for one always-visible QuickFilterField button per filterType-tagged column, next to the search box — plus a "Clear filters" link once a filter is active.
| INVO-2026-001 | 2026-04-21 | $200.00 | paid |
|---|---|---|---|
| INVO-2026-002 | 2026-04-25 | $450.00 | overdue |
| INVO-2026-003 | 2026-05-02 | $120.00 | draft |
| INVO-2026-004 | 2026-05-10 | $980.00 | sent |
| INVO-2026-005 | 2026-05-14 | $300.00 | disputed |
const columns: ColumnDef<Invoice>[] = [
{
field: 'status',
filterType: 'select',
filterOptions: [
{ value: 'draft', label: 'Draft', render: <Tag variant="white" label="Draft" /> },
{ value: 'paid', label: 'Paid', render: <Tag variant="greenSecondary" label="Paid" /> },
{ value: 'overdue', label: 'Overdue', render: <Tag variant="redSecondary" label="Overdue" /> },
],
},
{
field: 'amount',
filterType: 'range',
filterUnit: '$',
filterRangeBounds: { min: 0, max: 100_000 },
filterCurrencyOptions: [{ value: 'US', label: 'US' }, { value: 'EU', label: 'EU' }],
},
{ field: 'date', filterType: 'date' },
];
<DataTable columns={columns} rows={rows} showToolbar inlineFilters />;Each filterType compiles down to one of three built-in FilterItem operators — isAnyOf (select), betweenNumbers (range), betweenDates (date) — alongside the existing contains/equals/isEmpty/isNotEmpty, so programmatic/controlled filterModel usage works identically whether a filter came from the panel or an inline field.
Master-detail rows
Ava Carter | ava.carter@example.com | Engineering | |
|---|---|---|---|
Liam Nguyen | liam.nguyen@example.com | Design | |
Noah Silva | noah.silva@example.com | Sales | |
Emma Kim | emma.kim@example.com | Marketing | |
Mateo Costa | mateo.costa@example.com | Support |
Works combined with virtualized: unlike every other row's fixed height, a detail panel's content is arbitrary, so its real height is only known once it's actually rendered — estimatedDetailPanelHeight (default 160) is the assumed height used until then, so the closer it is to the real content's height, the less the scroll position/spacer height need to visibly correct themselves the moment a panel first mounts.
Row reordering
Ava Carter | ava.carter@example.com | Engineering | |
|---|---|---|---|
Liam Nguyen | liam.nguyen@example.com | Design | |
Noah Silva | noah.silva@example.com | Sales | |
Emma Kim | emma.kim@example.com | Marketing | |
Mateo Costa | mateo.costa@example.com | Support |
const [rows, setRows] = useState(initialRows);
<DataTable columns={columns} rows={rows} rowReordering onRowOrderChange={setRows} />;Drag the leading handle, or focus it and use Up/Down. Handles disappear the moment any column is sorted — matches the upstream reference's own rule that manual reordering is only meaningful on the unsorted, "natural" order.
Pinned columns
Pinned columns should specify an explicit width for pixel-accurate sticky offsets:
Ava Carter | $128,000.00 | Remote Team lead | |
|---|---|---|---|
Liam Nguyen | $98,000.00 | On-site | |
Noah Silva | $87,000.00 | Contractor | |
Emma Kim | $94,500.00 | Remote | |
Mateo Costa | $71,000.00 | On-site Team lead |
Column resizing
columnResizable renders a drag handle on the trailing edge of every resizable !== false column's header cell. Pointer drag is the primary interaction; the handle is also a real role="separator" — focus it and use Left/Right (Shift for a bigger step) to resize by keyboard.
Ava Carter | ava.carter@example.com | Engineering |
|---|---|---|
Liam Nguyen | liam.nguyen@example.com | Design |
Noah Silva | noah.silva@example.com | Sales |
Emma Kim | emma.kim@example.com | Marketing |
Mateo Costa | mateo.costa@example.com | Support |
<DataTable
columns={columns}
rows={rows}
columnResizable
defaultColumnWidthsModel={{ email: 260 }}
/>Resizing a pinned column updates its own sticky offset immediately (via columnWidthsModel) once the drag is released — sibling pinned columns don't reflow live mid-drag, only on release.
Column management
columnActions adds a kebab menu (edit/pin/duplicate/delete) to every column's header, and — when columnTypeOptions is also given — a trailing "+" add-column trigger in the table body, not the header row (only the first row's cell is clickable; the header's matching cell is purely decorative, continuing the strip's dashed border down from the top). DataTable is generic over Row and has no idea what fields exist on it or how a "type" maps to a real ColumnDef, so none of this mutates columns itself; every action is reported via a callback for you to apply to your own columns state.
"Edit" doesn't open a dialog — it turns the header cell itself into a text input (blur, Enter, or Escape exit edit mode). onEditColumn only fires once, on commit (blur/Enter with a non-empty, changed value), with the new headerName already trimmed.
"Pin left"/"Pin right"/"Unpin" need no callback at all — unlike duplicate/delete, DataTable already owns pinnedColumnsModel internally, so the menu toggles it directly. Pass pinnedColumnsModel/onPinnedColumnsModelChange yourself only if you need to read or control which columns are pinned from outside.
onAddColumn takes no argument — clicking the trigger adds the column immediately, with no type chosen yet. Every column's kebab menu (not just a newly-added one) has a "Select type" section (current column.type highlighted, changed via onChangeColumnType), and a just-added column has that same menu auto-opened, alongside rename mode, right away — so naming and typing happen in one flow, with type picked second, not gating the add itself.
Ava Carter | ava.carter@example.com | Engineering | |
|---|---|---|---|
Liam Nguyen | liam.nguyen@example.com | Design | |
Noah Silva | noah.silva@example.com | Sales | |
Emma Kim | emma.kim@example.com | Marketing | |
Mateo Costa | mateo.costa@example.com | Support |
const columnTypeOptions = [
{ type: 'currency', label: 'Currency', icon: <CurrencyDollar /> },
{ type: 'date', label: 'Date', icon: <CalendarBlank /> },
{ type: 'file', label: 'File', icon: <Paperclip /> },
{ type: 'text', label: 'Text', icon: <TextAlignLeft /> },
{ type: 'user', label: 'User', icon: <User /> },
];
<DataTable
columns={columns}
rows={rows}
columnActions
columnTypeOptions={columnTypeOptions}
onAddColumn={() => setColumns((prev) => [...prev, { field: `new_${prev.length}`, headerName: 'New column' }])}
onEditColumn={(column, headerName) =>
setColumns((prev) => prev.map((c) => (c.field === column.field ? { ...c, headerName } : c)))
}
onChangeColumnType={(column, type) =>
setColumns((prev) => prev.map((c) => (c.field === column.field ? { ...c, type } : c)))
}
onDuplicateColumn={(column) => setColumns((prev) => [...prev, { ...column, field: `${column.field}_copy` }])}
onDeleteColumn={(column) => setColumns((prev) => prev.filter((c) => c.field !== column.field))}
/>;Virtualization
virtualized row-windows a real <table> — spacer <tr>s pad above/below only the rendered row slice — rather than switching to div-based markup. Requires a bounded height and a fixed rowHeight (falls back to a density-derived height).
<DataTable
columns={columns}
rows={twoThousandRows}
virtualized
height={480}
rowHeight={56}
/>Column virtualization is out of scope — column counts are typically small enough not to need it.
Keyboard navigation
tabThroughCells/tabThroughHeader enable roving-tabindex + arrow-key navigation instead of plain browser tab order:
<DataTable columns={columns} rows={rows} tabThroughCells tabThroughHeader />Arrow keys move one cell/header-button in that direction (clamped at the grid's edges); Home/End jump to the row/header's edges; Ctrl/Cmd+Home/End jump to the very first/last cell.
Imperative handle
const ref = useRef<DataTableHandle>(null);
<DataTable ref={ref} columns={columns} rows={rows} />;
ref.current?.scrollToIndexes({ rowIndex: 50 });
ref.current?.scroll({ top: 0 });
ref.current?.focusCell(0, 0); // requires `tabThroughCells`Known scope boundaries
- Row spanning (automatic same-value vertical cell merging) is not implemented — only per-row column spanning (
colSpan). - Row pinning is not implemented — only column pinning.
rowReorderinghas noisValidRowReorder-style per-drop validation, and no drag-preview/icon customization — onlyisRowReorderable(per-row disable) is exposed.- Resizing a pinned column updates sibling pinned columns' sticky offsets once the drag ends, not continuously while dragging.
filterType: 'range''s dual-handle slider (filterRangeBounds) is explicit —DataTablenever auto-derives the domain from row data, so the slider only appears once you pass{ min, max }yourself.onAddColumn/onEditColumn/onChangeColumnType/onDuplicateColumn/onDeleteColumnare pure reports —DataTabledoesn't construct, validate, or apply any column change itself.focusCell(on the imperative handle) requirestabThroughCellsto locate cells by position.
Accessibility
- WAI-ARIA grid keyboard model via
tabThroughCells/tabThroughHeader(see above). - Row-header cell (
<th scope="row">) is set automatically on each row's first rendered column. - Sort/select-all controls are real
<button>/checkbox elements — keyboard-operable by default even withouttabThroughCells. - Empty and no-results states use
StateMessagewith distinct copy depending on whether filters are active.
Table
The dumb, presentational Table primitive family (Table/TableHeader/TableHead/TableBody/TableRow/TableCell/TableFooter/TablePagination) that DataTable composes — no sort/filter/selection logic lives here.
Data Table Cells
The pre-built cell renderer library for DataTable — CellText, CellCheckOption, CellActions, CellTags, CellAvatar, CellProgress, CellAmount, CellPaymentMethod, CellReviews, CellDate, CellDescription, CellFiles, and CellHeader.