Data Table
A thin composable layer over TanStack Table — the parts a connected table is assembled from.
Two halves of one pair.
Tableis markup only — semantic elements, tokenised chrome, no data layer, in the root barrel. You bring the rows and render them.- The parts here —
useDataTableplusDataTableRoot,DataTableToolbar,DataTableSearch,DataTableFacetFilter,DataTableViewOptions,DataTableContentandDataTablePagination— are that same table wired to TanStack Table: sorting, filtering, row selection, column visibility and paging.
That split is the engine rule. TanStack Table is a real dependency, which is why this half sits on its own entry point:
import { DataTableRoot } from "@kanzo-tech/ui/table";@tanstack/react-table is an optional peer, so import { Button } keeps working for everyone
who has not installed it.
| Contract | Region | Reward |
|---|---|---|
| Something is eating the bell-ropes | Thornmarch | 16 |
| A wyrm under the granary | Thornmarch | 33 |
| Nine goats, one road | Thornmarch | 5 |
| The miller's second cellar | Thornmarch | 13 |
| The orchard is counting wrong | Thornmarch | 15 |
Usage
Everything on this page lives on the @kanzo-tech/ui/table subpath, not the root barrel. Keeping
it off the root means import { Button } from "@kanzo-tech/ui" never pulls TanStack into the
bundle — only the apps that actually render a data table pay for it.
pnpm add @tanstack/react-tableimport {
type ColumnDef,
DataTableContent,
DataTablePagination,
DataTableRoot,
DataTableSearch,
DataTableToolbar,
sortableHeader,
useDataTable,
} from "@kanzo-tech/ui/table";const table = useDataTable({ columns, data });
<DataTableRoot table={table}>
<DataTableToolbar>
<DataTableSearch column="name" />
</DataTableToolbar>
<DataTableContent />
<DataTablePagination />
</DataTableRoot>Six lines, and every one of them is a decision you can change. There is deliberately no
<DataTable columns data /> preset over the top: a fixed arrangement of parts that already ship
is an example, not a component, and the first call
site that wants a seventh thing drops to the parts anyway. example-complete below is that
arrangement, in a file you can copy.
ColumnDef, Row, CellContext and Table are re-exported from the same subpath, so a consumer
types its columns without a direct TanStack import.
Sorting
Sorting is opt-in per column. TanStack marks every column sortable by default, but a column only
becomes clickable when its header is built with sortableHeader — a plain-string header stays
inert.
| Region | ||
|---|---|---|
| The tide brought back the wrong boat | 41 | Saltmere |
| Whatever is in the salt-house, it is not salt | 41 | Saltmere |
| The lighthouse keeper has stopped answering | 78 | Saltmere |
| Three fords, one stoneback | 38 | Saltmere |
| Harpies above the cliff road | 75 | Saltmere |
| Survey the drowned quarter before the spring tide | 18 | Saltmere |
| Do not let the assessor drink the sample | 7 | Saltmere |
| Bog-hounds, in threes, along the causeway | 36 | Duskfen |
DataTableSearch filters on the single column id you give it, and DataTableToolbar is what
puts it and any actions on one row. Render neither and there is no toolbar. DataTablePagination
hides itself whenever there is one page, no selection and no pageSizes.
Empty state
DataTableContent's empty prop takes whatever should stand in for the rows. An
Item composition is the usual shape.
| Contract | Reward |
|---|---|
Footer
Give a column a footer and the table renders a <tfoot> — totals, counts, a summary row.
Columns without one leave an empty footer cell; if no column defines a footer, no <tfoot> is
emitted at all.
| Contract | Reward | Region |
|---|---|---|
| Something is eating the bell-ropes | 16 | Thornmarch |
| A wyrm under the granary | 33 | Thornmarch |
| Nine goats, one road | 5 | Thornmarch |
| The miller's second cellar | 13 | Thornmarch |
| The orchard is counting wrong | 15 | Thornmarch |
| Escort the assessor as far as the ford | 6 | Thornmarch |
| A stoneback has taken the lower ford | 30 | Thornmarch |
| Audit the Ash & Co. expense ledger | 6 | Thornmarch |
| The children say the well talks | 32 | Greenhollow |
| Mimics in the tithe-barn | 15 | Greenhollow |
| Bog-hounds took the herd dog | 15 | Greenhollow |
| Grimalkin, but only at dusk | 14 | Greenhollow |
| Posted | 200 gold |
The parts
useDataTable owns the engine — every row model wired, the filter/sort/visibility/selection
slices held as state, manualPagination and friends passed straight through. It returns the
TanStack Table instance and nothing else, so anything the TanStack API can do is still on the
table. The parts own the chrome and read that instance off React context.
| Region | ||
|---|---|---|
| The tide brought back the wrong boat | 41 | Saltmere |
| Whatever is in the salt-house, it is not salt | 41 | Saltmere |
| The lighthouse keeper has stopped answering | 78 | Saltmere |
| Three fords, one stoneback | 38 | Saltmere |
Anatomy
const table = useDataTable({ data, columns })
<DataTableRoot table={table}>
├── <DataTableToolbar>
│ ├── <DataTableSearch />
│ ├── <DataTableFacetFilter />
│ └── <DataTableViewOptions />
├── <DataTableContent />
└── <DataTablePagination />Every part is optional and their order is yours — the toolbar is a plain flex row, so your own buttons go in it alongside ours.
DataTableSearch with a column filters that one column; without it, the table's global filter.
Row selection
selectColumn() returns a ready-made leading checkbox column: the header reflects a partially
selected page as indeterminate, both checkboxes carry a real aria-label (the column has no
header text to borrow), and the cell stops click propagation so it stays inert under
onRowClick. It is excluded from sorting and from the View menu.
Read the selection off the table instance — getFilteredSelectedRowModel().rows counts only rows
that survive the current filters, which is what a bulk action should operate on.
| Contract | Reward | Region | |
|---|---|---|---|
| Something is eating the bell-ropes | 16 | Thornmarch | |
| A stoneback has taken the lower ford | 30 | Thornmarch | |
| Audit the Ash & Co. expense ledger | 6 | Thornmarch | |
| The tide brought back the wrong boat | 41 | Saltmere |
DataTablePagination reports the selection count on its own, which is why it appears here even
though a single page fits.
Column visibility
| Contract | Reward | Region |
|---|---|---|
| Something is eating the bell-ropes | 16 | Thornmarch |
| A wyrm under the granary | 33 | Thornmarch |
| Nine goats, one road | 5 | Thornmarch |
| The miller's second cellar | 13 | Thornmarch |
| The orchard is counting wrong | 15 | Thornmarch |
| Escort the assessor as far as the ford | 6 | Thornmarch |
| A stoneback has taken the lower ford | 30 | Thornmarch |
| Audit the Ash & Co. expense ledger | 6 | Thornmarch |
| The children say the well talks | 32 | Greenhollow |
| Mimics in the tithe-barn | 15 | Greenhollow |
| Bog-hounds took the herd dog | 15 | Greenhollow |
| Grimalkin, but only at dusk | 14 | Greenhollow |
DataTableViewOptions lists every column whose getCanHide() is true, labelling each by its
header when that header is a string. A column whose header is sortableHeader("Reward")
is a render function, not a label, so the menu falls back to the column id. Keep the plain string
header, give the column an explicit id you are happy to show, or take it out of the menu with
enableHiding: false.
Set enableHiding: false on the column that identifies the row: a table whose name column can
be hidden is a table of anonymous rows. Initial state comes from
initialColumnVisibility on the hook.
Faceted filters
DataTableFacetFilter is a multi-select over a column's faceted values, with live counts. Leave
options out and it offers every distinct value it finds; pass them to narrow the list, relabel a
raw value or give it an icon. It is a FacetFilter — a popover over a listbox, because a filter is
a value and not a command — so the rows are ordered by label whichever way they arrive.
searchable adds a filter field above the values, for a column with more of them than anyone reads.
Search is honest and total here: the table already holds its rows, so the field narrows every value
the column has, rather than a page of them the way
a chart's facet filter does. It is a prop and not
a count threshold — a control that grows a field once the facet passes n values reshapes itself
under the cursor.
A faceted column must declare filterFn: facetFilterFn.
TanStack's built-in arrIncludesSome expects the cell to be the array. On a scalar cell it
degrades to substring matching, so filtering by "active" silently keeps "inactive" too.
facetFilterFn compares by equality against the selected values, which is what the filter means.
import { facetFilterFn } from "@kanzo-tech/ui/table";
{ accessorKey: "status", header: "Status", filterFn: facetFilterFn }| Contract | State | Posted by |
|---|---|---|
| The children say the well talks | Amber Hall | |
| Mimics in the tithe-barn | Amber Hall | |
| Map the drowned lane | Lanternwood | |
| Second attempt: the drowned lane | Lanternwood | |
| Bog-hounds took the herd dog | Amber Hall | |
| The hedge has moved eleven feet | Lanternwood | |
| Grimalkin, but only at dusk | Amber Hall |
Selecting nothing clears the column filter entirely rather than filtering to the empty set, and the popover stays open across selections — a filter is a set, not a single choice.
Pagination
DataTablePagination retires itself when there is nothing to say: one page, no selection and no
size selector means no row of empty chrome. Pass pageSizes for a rows-per-page selector, which
keeps the row alive even on a single page.
| Ref | Contract | Due |
|---|---|---|
| Q-1041 | Something is eating the bell-ropes | 1312-10-15 |
| Q-1042 | A wyrm under the granary | 1312-05-29 |
| Q-1043 | Nine goats, one road | 1312-07-24 |
| Q-1044 | The miller's second cellar | 1312-05-18 |
| Q-1045 | The orchard is counting wrong | 1312-06-17 |
Server-side pagination and sorting
Hand useDataTable any TanStack option and it goes straight to useReactTable, so the
server-driven modes work as documented: manualPagination with a rowCount tells the table how
many rows exist beyond the ones you handed it, and the matching row model steps aside.
manualSorting and manualFiltering behave the same way.
Read what to fetch off the table instance — table.getState().pagination and
.sorting — rather than through onPaginationChange, which hands you a TanStack Updater (a
value or a function) and leaves you to resolve it against the previous state yourself.
| Contract | Posted | |
|---|---|---|
| Reading the board… | ||
Everything together
Selection, search, two faceted filters, column visibility, sortable headers, row activation and a page-size selector.
| Region | State | Posted by | Due | |||
|---|---|---|---|---|---|---|
| Something is eating the bell-ropes | 16 | Thornmarch | Amber Hall | 1312-10-15 | ||
| A wyrm under the granary | 33 | Thornmarch | Amber Hall | 1312-05-29 | ||
| Nine goats, one road | 5 | Thornmarch | Amber Hall | 1312-07-24 | ||
| The miller's second cellar | 13 | Thornmarch | Amber Hall | 1312-05-18 | ||
| The orchard is counting wrong | 15 | Thornmarch | Amber Hall | 1312-06-17 |
Click a row to open the contract.
Your own parts
useDataTableContext() returns the table instance of the enclosing DataTableRoot, typed by your
row type, so a custom toolbar control is a component like any other:
function ResetFilters() {
const table = useDataTableContext<Quest>();
if (!table.getState().columnFilters.length) return null;
return (
<Button onClick={() => table.resetColumnFilters()} size="sm" variant="ghost">
Reset
</Button>
);
}The context value is not memoised, and that is deliberate.
TanStack keeps a single mutable table instance, so { table } never changes identity on its own.
Wrapping it in useMemo — the reflex an optimisation pass reaches for — freezes any part passed
down as children, whose element reference React reuses, on the state it first rendered with.
Nothing throws; the table just stops reacting to filters and paging.
DataTableRoot therefore allocates a fresh context object on every render. Leave it alone.
API Reference
useDataTable
Returns a TanStack Table<TData>.
| Option | Type | Default |
|---|---|---|
columns | ColumnDef<TData, TValue>[] | — |
data | TData[] | — |
pageSize | number | 20 |
initialSorting | SortingState | [] |
initialColumnFilters | ColumnFiltersState | [] |
initialColumnVisibility | VisibilityState | {} |
initialRowSelection | RowSelectionState | {} |
initialGlobalFilter | string | "" |
Every other TableOptions key — manualPagination, rowCount, getRowId, enableRowSelection,
onSortingChange, … — is forwarded untouched. Supplied onXChange handlers are called in
addition to the hook's own state, never instead of it.
DataTableRoot
| Prop | Type | Default |
|---|---|---|
table | Table<TData> | — |
Extends ark.div. useDataTableContext<TData>() reads the instance back out.
DataTableSearch
| Prop | Type | Default |
|---|---|---|
column | string | — (global filter) |
placeholder | string | "Search…" |
className sizes the input group — the visible box; every other prop lands on the input.
DataTableFacetFilter
| Prop | Type | Default |
|---|---|---|
column | string | — |
label | ReactNode | the column id |
options | DataTableFacetOption[] | every faceted value |
searchable | boolean | false |
DataTableFacetOption is { value: string; label?: ReactNode; icon?: ComponentType }.
DataTableViewOptions
| Prop | Type | Default |
|---|---|---|
label | ReactNode | "View" |
DataTableContent
| Prop | Type | Default |
|---|---|---|
empty | ReactNode | "No results." |
onRowClick | (row: TData) => void | — |
stickyHeader | boolean | false |
maxHeight | string | number | — |
className styles the bordered box; the remaining props reach the <table>, so variant and
isHoverable from Table pass through. With onRowClick the row
becomes tabbable and Enter/Space activate it — a <tr> is not an interactive role, so it has to
be made one. Activation is skipped when the key lands on a control inside the row.
Pinning the header
stickyHeader keeps the column names in place while the surrounding region scrolls. It is not
just a class on the cells, because two boxes in between are scroll containers and position: sticky resolves against the nearest one: the bordered box (overflow-hidden) and Table's own
wrapper (overflow-auto). Both are exactly as tall as their content, so a sticky th inside them
pins to something that never moves. The prop stands both down — the box to overflow-clip, which
still clips the rounded corners without scrolling — and the header then pins to whatever really
scrolls: a ShellMain, a dialog body, the page.
Which scroll container it pins to is what maxHeight decides, and the two answers behave
differently enough to pick between deliberately.
stickyHeader alone | stickyHeader + maxHeight | |
|---|---|---|
| Pins to | the enclosing region | the table's own scrollport |
| Scrollbars | one — the region's | two, once the rows exceed the height |
| Table wider than its box | unreachable | scrolls sideways |
Set maxHeight whenever the columns may not fit, which on a narrow viewport is most tables.
Pinning to the enclosing region stands the wrapper's overflow-auto down, and that is the same
declaration that let a wide table scroll sideways — CSS cannot keep one axis scrollable while the
other stays sticky. Without a height, columns past the right edge have nothing to scroll them and
simply cannot be read. The pinned cells are painted bg-background; on another surface pass
className="bg-card" to your TableHead.
DataTablePagination
| Prop | Type | Default |
|---|---|---|
pageSizes | number[] | — (no selector) |
Extends ark.div. Renders nothing when there is one page, no selection and no pageSizes.
selectColumn
selectColumn<TData>(options?): ColumnDef<TData, unknown>
| Option | Type | Default |
|---|---|---|
id | string | "select" |
headerLabel | string | "Select all rows on this page" |
rowLabel | (row: Row<TData>) => string | `Select row ${n}` |
sortableHeader
sortableHeader(label: ReactNode) returns a TanStack header renderer — use it as a column's
header.
facetFilterFn
A column filterFn that keeps rows whose value is one of the selected facets. Required on any
column driven by DataTableFacetFilter.
Every part's props are exported as an interface — DataTableContentProps,
DataTableFacetFilterProps, DataTablePaginationProps, DataTableRootProps,
DataTableSearchProps and DataTableViewOptionsProps — so a wrapper can take the same props
without restating them.
SelectColumnOptions is exported — the third argument to the checkbox column, carrying id,
headerLabel and a rowLabel(row) for the per-row accessible name. It is generic in the row type,
so a column built for your data keeps it.