UnflowUI
Components

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

PropTypeDefaultDescription
columnsColumnDef<Row>[]Required.
rowsRow[]Required.
getRowId(row: Row) => RowIdrow.idShould be memoized.
sortModel / defaultSortModel / onSortModelChangeSortItem[][]Single-column 3-state cycle (asc → desc → none) by clicking a header.
filterModel / defaultFilterModel / onFilterModelChangeFilterModel{ items: [] }{ items, logicOperator?, quickFilterValues? }. Built-in operators: contains/equals/isEmpty/isNotEmpty.
paginationModel / defaultPaginationModel / onPaginationModelChangePaginationModel | undefinedundefined (unpaginated){ page, pageSize }, 0-based page.
totalRowsnumber | 'unknown' | { estimate: number }filtered row countDrives the pagination footer's item count.
pageSizeOptionsnumber[][10, 25, 50, 100]
checkboxSelectionbooleanfalseAdds a leading select-all/select-row checkbox column.
selectionModel / defaultSelectionModel / onSelectionModelChangeSet<RowId>new Set()No selection UI renders unless this or checkboxSelection is set.
isRowSelectable(row: Row) => boolean
columnVisibilityModel / defaultColumnVisibilityModel / onColumnVisibilityModelChangeRecord<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 / onColumnOrderChangestring[]columns' own order
columnResizablebooleanfalseRenders a drag handle on every resizable !== false column's header cell.
columnWidthsModel / defaultColumnWidthsModel / onColumnWidthsModelChangeRecord<string, number>{}Per-field width (px) override from a resize — takes priority over that column's own static width.
columnActionsbooleanfalseAdds 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.
columnTypeOptionsColumnTypeOption[]{ type, label, icon? }[] offered in the "+" trigger's type picker. Omit to hide that trigger.
onAddColumn / onEditColumn / onChangeColumnType / onDuplicateColumn / onDeleteColumnsee belowDataTable doesn't construct or own columns — each is a pure report for you to act on.
showToolbarbooleanfalseMounts the default toolbar (quick filter + columns/filter panel triggers).
slots.toolbarComponentType<DataTableToolbarSlotProps<Row>>Full custom toolbar replacement.
inlineFiltersbooleanfalseReplaces the Filter Panel trigger with one QuickFilterField per filterType-tagged column. See Inline filters.
virtualized / rowHeight / height / overscanRowCountfalse / density-derived / — / 5Fixed-row-height windowing of a real <table>. height is required for virtualized to have a viewport to window against.
tabThroughCells / tabThroughHeaderbooleanfalseRoving-tabindex + arrow-key navigation (doc's two-boolean simplification of a 4-value tab-navigation enum).
getDetailPanelContent(row: Row) => ReactNodeAdds an expand/collapse toggle column + a full-width detail row. Works combined with virtualized — see Master-detail rows.
detailPanelExpandedRowIds / defaultDetailPanelExpandedRowIds / onDetailPanelExpandedRowIdsChangeSet<RowId>new Set()
estimatedDetailPanelHeightnumber160Assumed detail-panel height, only while virtualized, until the panel is actually rendered and measured once.
rowReorderingbooleanfalseAdds a leading drag-handle column. Disabled (handles hidden) while any sortModel entry is active — only meaningful on the "natural" order.
isRowReorderable(row: Row) => booleanDisables the handle for specific rows.
onRowOrderChange(rows: Row[]) => voidDataTable 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
refRef<DataTableHandle>{ scrollToIndexes, scroll, focusCell } — see below.

ColumnDef<Row>

FieldTypeDescription
fieldstringRequired, unique.
headerNamestring
descriptionstringShown as a header tooltip.
width / minWidth / maxWidthnumberFixed inline sizing.
accessor(row: Row) => unknownCheap — 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) => stringCheap, display-only — never seen by sort/filter.
cellComponentType<CellRenderProps<Row>>Expensive, opt-in — a component (hooks always safe inside it).
sortComparator(a, b, direction) => numberAlways receives direction explicitly — never auto-flipped.
filterFn(value, item, row) => booleanOverrides the built-in operators.
filterType'select' | 'range' | 'date'Renders this column as an inline QuickFilterField when DataTable's inlineFilters is set — see Inline filters.
filterIconReactNodeIcon 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).
filterUnitstringfilterType: '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 / userFilterablebooleanUI-only — still programmatically sortable/filterable via a controlled model even when false.
resizablebooleanUI-only — still programmatically resizable via a controlled columnWidthsModel even when false. Only relevant when columnResizable is set.
disableColumnActionsbooleanHides the edit/duplicate/delete menu for this one column even when columnActions is set (e.g. a required/system column).
align'left' | 'center' | 'right'
colSpannumber | ((row: Row) => number | undefined)Per-row column merging.

Basic

Ava Carter

ava.carter@example.comEngineering

Liam Nguyen

liam.nguyen@example.comDesign

Noah Silva

noah.silva@example.comSales

Emma Kim

emma.kim@example.comMarketing

Mateo Costa

mateo.costa@example.comSupport

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.comEngineering

Liam Nguyen

liam.nguyen@example.comDesign

Noah Silva

noah.silva@example.comSales

Selection

Ava Carter

ava.carter@example.comEngineering

Liam Nguyen

liam.nguyen@example.comDesign

Noah Silva

noah.silva@example.comSales

Emma Kim

emma.kim@example.comMarketing

Mateo Costa

mateo.costa@example.comSupport

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-0012026-04-21
$200.00
paid
INVO-2026-0022026-04-25
$450.00
overdue
INVO-2026-0032026-05-02
$120.00
draft
INVO-2026-0042026-05-10
$980.00
sent
INVO-2026-0052026-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.comEngineering

Liam Nguyen

liam.nguyen@example.comDesign

Noah Silva

noah.silva@example.comSales

Emma Kim

emma.kim@example.comMarketing

Mateo Costa

mateo.costa@example.comSupport

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.comEngineering

Liam Nguyen

liam.nguyen@example.comDesign

Noah Silva

noah.silva@example.comSales

Emma Kim

emma.kim@example.comMarketing

Mateo Costa

mateo.costa@example.comSupport
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.comEngineering

Liam Nguyen

liam.nguyen@example.comDesign

Noah Silva

noah.silva@example.comSales

Emma Kim

emma.kim@example.comMarketing

Mateo Costa

mateo.costa@example.comSupport
<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.comEngineering

Liam Nguyen

liam.nguyen@example.comDesign

Noah Silva

noah.silva@example.comSales

Emma Kim

emma.kim@example.comMarketing

Mateo Costa

mateo.costa@example.comSupport
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.
  • rowReordering has no isValidRowReorder-style per-drop validation, and no drag-preview/icon customization — only isRowReorderable (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 — DataTable never auto-derives the domain from row data, so the slider only appears once you pass { min, max } yourself.
  • onAddColumn/onEditColumn/onChangeColumnType/onDuplicateColumn/onDeleteColumn are pure reports — DataTable doesn't construct, validate, or apply any column change itself.
  • focusCell (on the imperative handle) requires tabThroughCells to 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 without tabThroughCells.
  • Empty and no-results states use StateMessage with distinct copy depending on whether filters are active.