@hojiakbar_dev/data-table
v0.19.0
Published
Headless-first React data table: nested column groups, expandable rows, column pinning, resizing, reordering, sorting and per-table persisted layout.
Downloads
4,545
Maintainers
Readme
@hojiakbar_dev/data-table
A React data table people can rearrange — and that remembers how they left it.
Nested column groups · server-side paging, filtering and row grouping · pinning, resizing and reordering · virtualised rows · per-user persisted layout · a token-driven theme
→ Open the live playground
Built on TanStack Table v9. Ships as a hook plus an optional styled shell, so you can take the behaviour and write your own markup.
Every rearrangement is a request, not a mutation: the table asks your server for a page and asks your code to commit an edit. It never writes to its own data, which is what lets one table sit in front of a hundred thousand rows it has never seen.
npm i @hojiakbar_dev/data-table @tanstack/react-table @tanstack/react-virtual- What it does
- Nested columns · Column widths · Large data
- Server-side data — filters on the wire, row grouping
- Editing cells · Row selection · Row numbers · Status bar
- Expandable rows · Two tables on one page · Persistence
- Styling — shadcn/ui, Material UI
- Headless use · API · Accessibility
- Development · Requirements · Licence
import { DataTable, useDataTable, localStorageLayout } from "@hojiakbar_dev/data-table"
import "@hojiakbar_dev/data-table/styles.css"
function Receipts({ data, columns }) {
const table = useDataTable({
id: "receipts", // required — see "Two tables on one page"
data,
columns,
storage: localStorageLayout(),
initialLayout: { columnPinning: { start: ["code"], end: ["status"] } },
})
return <DataTable instance={table} striped height={480} />
}What it does
| | |
|---|---|
| Nested column groups | Group headers to any depth. A column that sits above the deepest level spans down to meet the rows. |
| Pin columns | To the start edge, the end edge, or both. Pinned columns stay put while the rest scrolls, with a shadow marking the seam. |
| Resize columns | Drag the right edge of a header; double-click it to fit the column to its content. A group header's edge resizes every column under it. Columns are never stretched to fill the container. |
| Reorder columns | Drag a header onto another; the column it will land on is outlined. Also from the keyboard, in the Columns panel. |
| Sort | Click a header: ascending, descending, off. Multi-sort shows its position. |
| Quick search | One box over every searchable column. Every token must appear somewhere on the row; different tokens may match different columns. |
| Filter columns | Text, number, date, boolean and values-list filters, from the header menu or the side panel's Filters tab. Each one is published as an explicit operator a backend can translate. |
| Hide columns | From the Columns panel. |
| Select rows | A leading column of checkboxes whose header takes everything the current query matches, not the page on screen — so approving 25 000 receipts is one click, not 500 screens. Or { scope: "page" }, for a backend that can only act on ids. Off by default. |
| Number the rows | An optional leading column carrying each row's place in the whole result set — not in the page. Off by default. |
| Totals footer | A row under the body, aligned and pinned with the columns, holding a total you supply per column. The library computes none of it. |
| Expand rows | A detail panel under a row, child rows that indent by depth, or both. Nesting is unlimited. |
| Timeline (Gantt) | A time scale after the columns: each row draws its plan, the work done and what ran late as bars, due days and payments as points, under today and a deadline. Day, week or month zoom; the pinned columns stay while it scrolls. |
| Per-column menu | Right-click a header, or use its ⋮ button: sort, pin, fit width, hide. A host's own utility column (a checkbox, a row-actions button) opts out with meta: { menu: false }, and names itself with meta: { label } where its header is drawn rather than written. A column that stands for a thing can put a mark before its name with meta: { icon }. |
| Edit a cell | Right-click a body cell and choose Edit. Text, number, date, boolean and single-choice list editors. The edit is a request to your onCellEdit — the table never writes to its own data. |
| Remember all of it | Per table, per user, wherever you choose to put it. |
Nested columns
Use TanStack's group() helper. Wrap each group's children in columns() so
TypeScript keeps each column's own value type:
const col = createColumnHelper<DataTableFeatures, Receipt>()
const columns = [
col.accessor("code", { header: "Code", size: 100 }),
col.group({
id: "document",
header: "Document",
columns: col.columns([
col.accessor("partner", { header: "Partner", size: 240 }),
col.accessor("city", { header: "City", size: 170 }),
]),
}),
col.accessor("status", { header: "Status", size: 120 }),
]┌──────┬──────────────────────┬────────┐
│ │ Document │ │
│ Code ├───────────┬──────────┤ Status │
│ │ Partner │ City │ │
├──────┼───────────┼──────────┼────────┤Code and Status span down to the rows on their own; you do not declare that.
A group header carries no sort control — sorting acts on one column — but it is
dragged and resized as a unit: dragging a group's edge scales every column
under it by the same proportion, and dragging the header itself moves the whole
group, every leaf in its existing order.
A group moves among its siblings at its own level, which is the rule its
leaves already obey one level down: a group is never dropped inside another
group, and a leaf is never dropped onto a group. dropRegionOf owns that
boundary for every surface, so no slot is ever drawn where the drop would be
refused — see Accessibility for the keyboard path.
Widths are declared in a <colgroup> rather than on each cell. Under
table-layout: fixed the browser reads widths from the first row only, which
with grouped headers is a row of spanning cells — so per-cell widths get
divided evenly and every column comes out the wrong size.
Column widths
Every column is exactly as wide as it says. The table is as wide as its container or as wide as its columns, whichever is larger, and any space left over goes to a blank filler column between the scrolling columns and the end-pinned ones — the way AG Grid leaves room after its last column.
The alternative, stretching columns to fill the container, is what makes resizing feel broken: every rendered width then differs from the declared one, so dragging one edge visibly moves every other column, and pinned offsets (which are sums of declared widths) land in the wrong place.
Widths are clamped to minColumnWidth / maxColumnWidth (or a column's own
minSize / maxSize) when they are written, so a stored layout never holds a
width the table would refuse to render. A press-and-release on a handle changes
nothing and marks nothing.
Fit to content. Double-click a handle, press Enter on it, or use Fit this column / Fit all columns from the column menu. Fitting measures the rows currently rendered — header, cells, and in the first column the expand toggle and indent of nested rows. On a virtualised table (the default — see Large data) "currently rendered" means the current viewport, not the whole dataset: a wide value scrolled out of view is not accounted for until it is fitted again. Fit all columns also keeps every group label readable by widening the group's children when they come up short.
Keyboard. With a resize handle focused, ← and → change the width by 10px, Shift-← and Shift-→ by 50px, Enter fits the column.
Right-to-left. Pass direction: "rtl" to the hook so a drag away from the
column widens it there too.
Large data
Rows are virtualised: only the rows in view (plus a few either side) are in
the DOM, whatever the row count. Data rows have a known height —
rowHeight (default 40) or getRowHeight(row) — so the scrollbar is exact
without measuring anything; only open detail panels are measured. Pass
virtualize={false} to render every row (printing, very small tables).
useDataTable({ id: "receipts", data, columns, rowHeight: 32 })
// or, when rows vary in height:
useDataTable({ id: "receipts", data, columns, getRowHeight: (row) => rowHeightFor(row) })getRowHeight may be written inline like that: its identity is not a
measurement input, so a new arrow on every render costs nothing. It does have
to be a pure function of its row.
Changing what it answers. A density toggle, or any other swap of the height policy for rows the table already holds, is noticed by asking the function again — for every row on screen, and for 16 rows spread evenly over the whole list. That second sample is what catches a change below the fold, and it is deliberately a fixed 16 rather than every row: this runs on each render, and a 100 000-row table has to stay cheap. So the promise is exact and bounded rather than unconditional:
- a change over any run of about
rowCount / 16neighbouring rows — every row, every row of a kind, a whole region — is seen at once, wherever it is and whether or not any of those rows are on screen; - a narrower change with no row on screen in it (one outlier row 10 000 places down) is the one the sample can step over. Nothing is wrong on screen, but the scrollbar is short by the difference until those rows are scrolled to.
Pass heightVersion when your policy can change that narrowly — any value
that changes with the policy. It re-estimates every row at once:
useDataTable({ id: "receipts", data, columns, getRowHeight, heightVersion: density })It costs one pass over the rows each time the value changes, which is why it
is opt-in rather than the default. A change to data or to rowHeight
already re-estimates everything on its own — heightVersion is only for a
policy that moves while both of those stand still.
Virtualisation needs something to scroll. The table's root has no height
of its own, so a table given neither height nor an ancestor with a height
grows to fit its rows: nothing scrolls, the visible "window" is the whole
list, and 100 000 rows go into the DOM. Give it a bound:
<DataTable instance={table} height={520} /> // the table's own box
<div style={{ height: "100%" }}><DataTable … /></div> // or an ancestor'sA table that ends up unbounded anyway is not left to freeze: once measured as
unable to scroll while holding more rows than any screen can show, its
viewport falls back to --dt-viewport-max-height (70vh) and says so in the
console in development. Set the token to none to opt out of the rescue. A
table that already scrolls — bounded by the prop, by an ancestor, by anything
— is never touched by it.
<DataTable> sets --dt-row-height as an inline style on its root
element — it has to, so the virtualiser's row estimate and the CSS token
never disagree. An inline style beats every selector-based rule short of
!important, so .my-app { --dt-row-height: 32px } in a stylesheet has no
effect on it. Reach for !important if you must override it from CSS; the
supported way is to pass rowHeight / getRowHeight instead, which is what
actually drives both the row and the token.
Server-side data
Set mode: "server" and the table stops sorting and paging: data is one
page, already sorted, and the table tells you what it wants through a
TableQuery — sorting, filters, quick search, row grouping and pagination.
With TanStack Query:
const EMPTY: Receipt[] = [] // stable identity, so an empty page isn't a new `data` array every render
const [query, setQuery] = useState<TableQuery>()
const { data, isFetching, error, refetch } = useQuery({
queryKey: ["receipts", query],
queryFn: () => api.receipts(query!),
enabled: query !== undefined,
placeholderData: keepPreviousData,
})
const table = useDataTable({
id: "receipts",
columns,
mode: "server",
data: data?.rows ?? EMPTY,
rowCount: data?.total, // undefined until the response arrives; rowCount accepts that
getRowId: (row) => row.id,
onQueryChange: setQuery,
})
<DataTable instance={table} loading={isFetching} error={error} onRetry={refetch} />onQueryChange fires once on mount with the persisted sorting and page size,
then on every change. Sorting resets the page to the first; a new data
array does not. Give rows a stable getRowId so expansion follows records
across pages. instance.query holds the same object and only changes
identity when its contents change.
One more, optional, field on the answer: unfilteredTotal. It is
rowCount before query.filters and query.search narrowed it, for the
status bar's "X of Y" — SELECT COUNT(*) FROM receipts with no
WHERE, run alongside the filtered count your endpoint already computes for
rowCount. Wire it exactly like rowCount:
unfilteredTotal: data?.unfilteredTotal, // optional; undefined reads as "state only what matched"Leave it off a response and the status bar states just the matched count — the graceful case a backend can ship before this extra query exists, not a state it has to guard against.
Pagination is on by default in server mode and off in client mode; pass
pagination: { pageSize: 100, pageSizeOptions: [50, 100, 500] } to change
either. The footer shows the total, a page-size select, the current range and
first/previous/next/last controls with a page number box. The chosen page
size is persisted with the rest of the layout.
If your own pager pages the rows — the page lives in the URL, or a pager is
shared with other widgets — pass pagination: "external". The table then
renders exactly the rows it is given, with no footer and no page state of its
own (what false does), and skips the development warning false earns in
server mode. query.pagination keeps its untouched default window; read the
page from your own state, not from the query. The table does not know your
page's offset, so with rowNumbers on it counts each page from 1 and
aria-rowcount covers the rows it was given — show your own numbers, or keep
rowNumbers off, when your pager pages.
States. loading with no rows shows skeleton rows; with rows it shows a
progress bar and dims them. error shows a banner with a Retry button that
calls onRetry; rows already on screen stay put.
The empty state waits for the first answer. The initial query is announced
from an effect, one commit after mount, so a server table always commits at
least one render with no rows, no error and isFetching === false — the query
above is still disabled — and a host that starts its request from an effect of
its own commits a second. Those look exactly like "the server has nothing",
and a table that believed them would flash "No rows" before its first
skeleton. It does not: in server mode the empty state waits until the host has
answered once, where an answer is rows, a rowCount (0 counts — an empty
page is an answer), an error, or loading turning true. The recipe above
reports all four, so copying it is enough.
The other side of that guarantee: a server table whose host reports none of
the four has said nothing the table can read, and keeps its skeleton rather
than claiming an emptiness nobody confirmed. Pass rowCount with each page —
server mode needs it for the footer anyway — and loading while the request
is out. A shell of your own has to draw the same line: no rows, no error,
nothing loading and rowCount === undefined means the query has not been
answered yet, not that the answer was empty.
Filters on the wire
A filter is not a client-side trick that happens to work remotely: it is a value you hand to your backend and translate into SQL without interpreting anything.
{
"sorting": [{ "id": "created", "desc": true }],
"filters": [
{ "kind": "number", "field": "amount", "op": "between", "from": 1000000, "to": null },
{ "kind": "date", "field": "created", "op": "range", "from": "2026-03-01", "before": "2026-04-01" },
{ "kind": "text", "field": "partner", "op": "contains", "value": "agro" },
{ "kind": "list", "field": "status", "op": "in", "values": ["in_process", "open"] }
],
"search": { "text": "KR-102", "fields": ["code", "partner", "status"] },
"grouping": [],
"expanded": [],
"pagination": { "pageIndex": 0, "pageSize": 50 }
}filters is a flat array, implicitly ANDed, and sorted by field — which is
why amount comes first here rather than the order the user set the filters in.
Sorting it is what stops a column drag changing the query string and making you
refetch an identical result set. When cross-column OR eventually ships it will
arrive as a new optional field, never as a change to the element type, so a
backend written against this shape keeps working.
| Operator | On | Means |
|---|---|---|
| contains / notContains | text | Substring, case-insensitive |
| equals / notEquals | text | Whole value, case-insensitive |
| startsWith / endsWith | text | Case-insensitive |
| eq ne lt lte gt gte | number | |
| between | number | Inclusive on both ends; null is unbounded |
| range | date | from <= value < before; either bound may be null |
| is | boolean | |
| in / notIn | list | |
| blank / notBlank | every kind | Nullish or empty, and its complement |
A published operator's meaning never changes; new behaviour gets a new name. Four rules are easy to get wrong, and the table itself follows all four.
All six text operators are case-insensitive, equals included. A backend
using a case-sensitive collation will return different row counts from client
mode for the same filter, and your users will report that as a data bug.
between is inclusive on both ends. AG Grid's number inRange is exclusive
by default, so a backend ported from it will disagree.
Negated operators never match a blank value. notContains, notEquals and
notIn exclude a row whose value is NULL or '', because that is what
NOT (col ILIKE …) does in SQL, where a comparison against NULL is NULL rather
than true. The number comparators do the same: a nullish value satisfies none of
them. blank is the operator for reaching those rows, and blank / notBlank
partition every row between them:
-- blank
(col IS NULL OR col::text = '')
-- notBlank
(col IS NOT NULL AND col::text <> '')The IS NOT NULL guard is not optional — the naive col <> '' silently excludes
every NULL through three-valued logic, and then the two operators no longer
partition the table. For a non-text column the '' half is always false and may
be dropped.
A date range is half-open, always: from is inclusive, before is exclusive.
One clause covers every case, and it is correct whether the column is a date or
a timestamptz:
(:from IS NULL OR created >= :from)
AND (:before IS NULL OR created < :before)The bug this prevents: an inclusive <= '2026-03-31' against a timestamp column
silently drops every row recorded during that last day. The table never emits a
time or a zone — a day is a day in the user's calendar. If you store instants,
converting the day boundary into your own zone is your decision to make and to
document.
Quick search is AND over tokens, OR over fields. Split text on whitespace;
every token must appear, case-insensitively, in at least one of fields on that
row; different tokens may match different columns. The naive contains: text
across the fields disagrees with client mode the moment a user types two words.
const { text, fields } = query.search!
where: {
AND: [
{ amount: { gte: 1000000 } },
{ created: { gte: new Date("2026-03-01"), lt: new Date("2026-04-01") } },
{ partner: { contains: "agro", mode: "insensitive" } },
{ status: { in: ["in_process", "open"] } },
// Every token must hit some field; different tokens may hit different fields.
...text.split(/\s+/).map((token) => ({
OR: fields.map((f) => ({ [f]: { contains: token, mode: "insensitive" } })),
})),
],
}Every text operator carries mode: "insensitive", equals included. The
new Date("…") calls are the backend choosing to read a calendar day as UTC
midnight, which is its prerogative and its decision to document.
fields is a list of columns your backend should be prepared to search. Quick
search over unindexed text columns is a good way to take down a database with
three characters; mark sensitive or unindexed columns as unsearchable so they
never reach fields.
The operator vocabulary is closed. The operators in the table above are the
whole list, and a condition carrying anything else is dropped rather than
published — a backend cannot be expected to translate an operator it has never
seen, and minting one locally would produce a filter that works in client mode
and silently does nothing in server mode. A host that needs different matching
keeps the vocabulary and changes the client half of it: columnDef.filterFn
accepts a function as well as a name, such a function needs no registration, and
it receives the column's own FilterCondition as its filter value — so it can
change how contains matches without inventing a matchesRegex. A host that
needs something the vocabulary cannot express at all turns the built-in filter
off for that column with meta: { filter: false } and keeps its own control
beside the table; what reaches query.filters is always one of the conditions
documented here.
meta.filter picks the editor; it does not type the value. A condition's
value is not checked against the column's own TValue, because columns is
ColumnDef<…, any>[] and there is no per-column value type left to check it
against. meta: { filter: "number" } on a text column compiles, and the
mismatch turns up at runtime as a filter that matches nothing. Declare the kind
that matches the data; a column that declares nothing has its kind inferred from
its first non-null value — string → text, number → number, boolean → boolean,
anything else → text — which is a convenience and not a contract, and is why a
stored condition is dropped on load when the kind it was built for is no longer
the kind the column resolves to.
Build conditions with the exported constructors — textCondition,
numberCondition, dateCondition, booleanCondition, listCondition — and
never by hand. They fix each condition's key order, sort a list's values and
return null for a condition that constrains nothing, and instance.query's
identity depends on all three.
Which columns fields holds is every visible, accessor-backed column whose
meta.searchable resolves true, plus every visible, accessor-backed column
that has neither a declared meta.searchable nor a sampled value yet — an
unresolved column stays in fields rather than being dropped from it. The
default for meta.searchable, once a sample exists, is "the column's first
non-null value is a string or a number", so a numeric column is searched too —
mark anything unindexed or sensitive meta: { searchable: false }.
columnDef.enableGlobalFilter: false also takes a column out of fields,
ahead of meta.searchable and inference alike: TanStack's own client-side
global filter honours that flag regardless of what this library's gate says,
so fields has to agree with it or a server honouring the wire's fields
would return rows the same table, in client mode, would show none of.
That unresolved rule has a consequence in server mode: a column with nothing
declared is unresolved at mount, before the first page has arrived, so fields
can change — narrower or wider — once real data lands and a sample is found.
A column resolves at most once from inference, though: once a sampled value has
settled it one way or the other, that verdict is cached for the life of the
table, so a later page whose sample happens to be all-null cannot re-open the
question. That cache only ever applies to inference — a column whose
searchability is declared (meta.searchable, or enableGlobalFilter: false)
is never cached and always reads the current declaration, so flipping it after
mount (behind an async permission check, a "search this column" toggle) takes
effect on the very next render, narrowing or widening. Set
filtering.searchFields explicitly to skip inference altogether, including the
one request its first resolution can cost.
Hiding a column narrows the search, which is surprising either way and is why
filtering.searchFields overrides the list outright. What it cannot override
is what the client will actually match: an entry naming no column, naming a
display column, or naming one with enableGlobalFilter: false is dropped, with
a dev-mode warning naming it, because TanStack refuses those three underneath
us and the wire would otherwise ask a backend to search columns this table
searches none of. Name the live id — a nested accessorKey like
"partner.name" has id "partner_name". search is null when the box is
empty, when it holds only whitespace, and when no column is searchable at all —
including when every searchFields entry was dropped — the client has nothing
to match against either, so both modes return everything.
The published value is debounced, by filtering.debounceMs (default
300 ms). The box itself stays responsive: the raw text is in state on the
keystroke, and what waits is the query. Column filters are never debounced —
they commit on Apply, Enter or blur. Nor is a programmatic write: clearAll()
and setModel() publish their search with their filters, in one query, so
clearing the toolbar or restoring a shared URL never announces an intermediate
request a host would fetch.
A values filter's choices come from exactly one source, in this order:
meta.values on the column, wherever it is declared and in either mode, shown
without counts; otherwise, in client mode, the data itself, with counts, and
narrowed by whatever the other columns are filtered by; otherwise, in server
mode, filtering.loadValues(columnId, { search, signal }). A server-mode list
column with neither is disabled with a label rather than shown an empty list —
an empty list reads as "there is no data". While a request is out the previous
answer stays on screen, dimmed and aria-busy, and a rejected one keeps it and
offers a retry; signal aborts a superseded request. Declaring meta.values on
a client-mode column trades the free counts for fixed labels, which is a real
trade.
Your own filters in the Filters tab
Some backends do not translate conditions at all: their list endpoint takes a
few fixed parameters — a role id, a status — one value each. Offering "any of
these", "not", "blank" over such a list would show an unfiltered page under a
filter's name, so turn the columns' filters off (meta: { filter: false })
and draw the parameters the endpoint really takes, with your own fields,
inside the side bar's Filters tab:
<DataTable
instance={table}
filtersPanel={{
content: (
<>
<RolePicker value={roleId} onChange={setRoleId} />
<StatusSelect value={status} onChange={setStatus} />
</>
),
activeCount: [roleId, status].filter(Boolean).length,
}}
/>contentis drawn at the top of the tab, above any column filters. The fields are yours: they apply however you make them apply, and you reset the page when they change (instance.pagination.resetPage()).activeCountis shown on the rail's Filters tab while above 0, and spoken as words after its name ("Filters, 2 active") — the panel is usually shut, and the count is what says why a row is missing.- With
filtersPanelthe tab is offered even when no column can be filtered, and then it draws only your fields: no "No filters applied", no "Clear all filters" for filters that cannot be made there. - Without it, a table with no filterable column has no Filters tab. The tab used to appear whenever quick search was on, and opened onto an empty note and a disabled button.
Row grouping
Drag a column into the Row groups zone in the side panel — or send it there from the keyboard — and the rows group by it. The grouping is computed by your server, not in the browser: grouping the fifty rows a pager handed you and presenting the answer as if it were the whole table is not merely incomplete, it is wrong in a way the user cannot see.
Grouping is server-side. The table holds one page of fifty rows out of a
hundred thousand; grouping those fifty would present a partial answer as if it
were the whole table — counts that are wrong, and wrong in a way the user
cannot see. So the grouping travels on the query and your backend answers it.
There is no client-mode fallback: instance.grouping refuses to set one, and
says so in a development warning.
instance.grouping.add("status") // group by status
instance.grouping.add("partner") // nest partner inside it
instance.grouping.toggle(["received"]) // open a group, by its key path
instance.grouping.clear() // every column back exactly where it wasTwo fields go out and one comes back:
{
"grouping": ["status", "partner"],
"expanded": [["received"], ["received", "Toshkent Kimyo Zavodi"]]
}grouping is column ids, outermost first — its order is its nesting, so
unlike filters it is not sorted. expanded is the exact key paths of the
groups the user has opened, canonicalised so that opening A then B produces
the same query as opening B then A.
The answer is a page of the flattened visible rows — what the user would see with those groups open — where a row is either one of your records or a group header:
interface GroupRow {
kind: "group"
/** The key path identifying this group, outermost first. */
path: (string | number | boolean)[]
/** How many LEAF rows are under it, at every depth. */
count: number
}Return them interleaved in data, in visible order. The table recognises a
group header by its kind and hands one to no callback of yours — not
getRowId, getRowHeight, getSubRows, canExpand, a cell renderer or
renderDetail — so your accessors never see a row they have no fields for.
kind: "group" is therefore reserved: a record of your own carrying it
would be drawn as a header.
rowCount is the length of the whole flattened list, group headers included:
they are rows the pager pages past, and a total of records alone would let it
run off the end.
For the same reason, row numbers count group headers too — they are rows of the list being looked at, and the offset a page carries is the only thing either feature can count from.
One more field on the answer: startPath. With a group open and a page
boundary falling inside it, a page comes back as records with no header above
them — and a record carries no path, so nothing in the rows says which group
they belong to. Report the open group the page's FIRST row sits inside ([]
at the top level) and the table draws a "continued" header above them. Only
the first row can ask: every later row's context is re-established by the
header above it, which is why this is one field per page rather than a path
per row.
useDataTable({ mode: "server", data: page.rows, rowCount: page.total, startPath: page.startPath, … })The SQL. Group the level being listed, filtered by the open path, and count:
SELECT COALESCE(NULLIF(status::text, ''), '') AS key, COUNT(*) AS count
FROM receipts
WHERE <the filters and the search, exactly as above>
GROUP BY 1
ORDER BY (key = '') , key -- blanks above every value, as everywhere elseCOALESCE(NULLIF(col::text, ''), '') and not a bare GROUP BY col: a blank
key is one group, keyed by "". A literal group-by gives two — one for
NULL, one for '' — and the contract does not survive that split, because
blank matches both: a "(Blanks)" filter would report twenty thousand rows
beside two headers dividing them, the filter and the counts disagreeing about
the same word.
Order of operations, and it matters: filter and search, then group, then sort within a level, then flatten against the open paths, then page. A backend that groups before it filters returns counts that do not match the rows under them.
Sorting a grouped table sorts within its level. Group headers are ordered by their own key; the level whose column the sort names takes the sort's direction while every other level stays ascending; records are ordered inside the innermost group they belong to and never move between groups. Blanks rank above every value for group keys exactly as they do for records, so one rule covers both kinds of row.
The Row Groups zone sits in the side bar's Columns tab, under the column tree. Drag a column into it — from its row in that tree, or straight off its header — and the table groups by it; drag a second one in and it nests inside the first. Each level is a chip, outermost at the top, removable by its own button and draggable to renest. While nothing is grouped the zone is a dashed area saying what it is for, rather than an invisible target. The drop affordance is the one the columns already use: the chip standing where the dragged column will land is outlined, and when the destination is past the last level a chip is drawn for the incoming column so there is something to outline.
Nothing here needs a pointer. Every row in the Columns tab carries a group
toggle — Group rows by Status, and Remove Status from row groups once it is
one — and a chip is renested with the same keys the column list uses: Space to
pick it up, the arrows to move it, Space to drop it, Escape to give up, with
each step announced.
The zone only exists where grouping can work. instance.grouping.enabled
is false on a client table, so there is no zone on one: a drop that grouped
fifty rows out of a hundred thousand would answer with counts for the page, and
a target that took a drop and did nothing would be worse still.
In the table, a grouped column leaves the body and its slot becomes the
group column, holding the chevron, the value and the count — received
(25 000) — with a mark in its header. It keeps its header, so sorting it
still reorders that level. Nesting a second level hides that column's own
slot; removing the grouping puts every column back exactly where it was,
because the derived visibility never touches the layout the user arranged.
The group column has a floor under its width while it is grouped, because
its slot was sized for that column's values and not for a chevron, a value and
a count side by side — a 90px Status would crowd all three. The floor starts
at 200px and gains one indent step per extra level, since each level pushes the
chevron further in. It is a floor and not a clamp: a width you set yourself, by
the resize handle or by columnSizing, wins outright, and nothing is written
into the saved layout, so taking the last chip out restores the column exactly.
groupColumnMinWidth(levels) is exported for a shell that wants the same rule.
Opening a group is a refetch, so the loading treatment is the one for a
page that is already on screen: the rows stay and a progress bar shows.
Grouping and its open branches are part of the saved layout, beside
sorting. Every change to either resets the page, the way sorting does.
What is deliberately not here. Aggregations (AG Grid's Values zone) are
out of this version — a header carries its value and its count and nothing
else. So is "expand everything": expanded is exact key paths, which is right
for the semantics and fine at any depth, but opening every group of a
high-cardinality column would put one path per group into a query that is
compared by JSON.stringify on every render. If one is ever wanted, expanded
gains a companion rather than growing. And there is no per-group lazy fetch:
one flat page keeps one request and one answer, and composes with the paging
and virtualisation that already exist. The cost is the refetch on expand; the
shape leaves room for the lazy version later, because expanded already
travels as key paths.
Turn it off with features: { grouping: false } when your backend cannot
group. Grouping is already server-only; this flag is for the server host
whose list endpoint has nothing more than paging, sizing and a search string
— no group-by at all — so the table would otherwise offer a Row groups zone
the backend can never answer. Default true, so every existing server host
is unaffected. With it off there is no Row groups zone, no per-column group
toggle, query.grouping and query.expanded stay [], and
instance.grouping.add/toggle refuse with the same development warning
client mode already gives. A layout saved while grouping was on keeps its
grouping in storage untouched — turning the flag back on picks it back up —
but the table renders and reports as ungrouped while the flag is off.
useDataTable({ id: "receipts", data, columns, mode: "server", features: { grouping: false } })Editing cells
Right-click a body cell; the first item in the menu is Edit. The cell becomes an editor with its value selected, Enter or a click away saves, Escape cancels, and Tab saves and moves to the next editable cell in the row, wrapping to the next row at the end.
A column opts in through meta.editable, and the table sends every commit to
onCellEdit. Both are required: a column that declares one without the other
is a misconfiguration, and the table says so once in development and leaves
Edit disabled.
const col = createColumnHelper<DataTableFeatures, Receipt>()
const columns = [
col.accessor("code", { header: "Code", meta: { editable: "text" } }),
col.accessor("date", { header: "Date", meta: { editable: "date" } }),
col.accessor("partner", {
header: "Partner",
// A single-choice list. Its options are `meta.values` — the same list a
// list FILTER uses, because "which values may I write" and "which values
// exist" are usually the same question asked twice.
meta: { editable: "list", values: PARTNERS.map((value) => ({ value })) },
}),
col.accessor("amount", {
header: "Amount",
// The predicate form: the column offers an editor, and this row's own
// state takes it away. Edit is then disabled saying the ROW refused it,
// not the column.
meta: { editable: (row: Receipt) => row.status !== "closed" },
}),
]
<DataTable
instance={table}
onCellEdit={async ({ row, columnId, value, previous }) => {
await api.patch(`/receipts/${row.id}`, { [columnId]: value })
refetch()
}}
/>The five kinds are the filter kinds, deliberately: a column that filters as a
date edits as a date. A predicate names no kind, so the kind is inferred the
way the filter's is — from meta.filter when it is set, otherwise from the
values. Declare the kind outright wherever it matters.
Optimism. The new value shows while your promise is in flight, marked as pending; the cell keeps its own renderer, so a formatted column stays formatted. A rejection reverts it and puts the reason in a notice that stays until it is dismissed — there is no timer, because a notice that removes itself removes itself while somebody is reading it.
After a successful write a server-mode host usually refetches. The optimistic value stands until that answer arrives and then steps aside for it, whatever it says — including a value the server normalised into something else. Nothing fights.
previous is the value the cell held when the editor opened, not one read
back at commit time, so a refetch in between cannot rewrite what the edit was
from.
What it will not edit. A group row and the grouped column have no value of their own; both offer the menu with Edit disabled, saying so. A cell whose column declared nothing does the same. A menu that sometimes fails to appear teaches people the feature is broken, so it always appears — and the browser's own context menu is left alone over a table that no column made editable.
An edit can make its row vanish. Change a cell a filter or the search is looking at and the next refetch will not return that row. That is correct, and it looks exactly like a bug, so the table says what happened. The same notice covers an editor whose row left the page underneath it — scrolled out of a virtualised body, or refetched away: the edit is abandoned rather than written, and never in silence.
Editing is pointer-only for now
There is no focused-cell model in this library yet, so there is nothing for the ContextMenu key or Shift+F10 to anchor a menu to, and no way to reach an editor without a pointer. Roving
tabindexacross body cells is the conventional answer and it is a bigger change than it looks; it is its own piece of work. Until it lands, do not ship cell editing as the only route to something a keyboard user must be able to do.
Row selection
Off by default, like row numbers and for the same reason — this adds a column rather than turning an interaction off — plus one of its own: a table that started selecting rows on a minor upgrade would put a bulk action in front of users you never meant to offer one to.
useDataTable({
id: "receipts",
data,
columns,
mode: "server",
rowCount: page.total,
getRowId: (row) => row.id, // effectively required; see below
features: { selection: true },
onSelectionChange: setSelection,
})The header checkbox means every matching row
Not the fifty on screen. A user approving 25 000 receipts must not have to page through 500 screens, and a table that made them would be offering a feature that does not do the job it exists for.
That decides the shape of everything else, because 25 000 ids is not a thing
to hold, to publish or to put in a WHERE clause — and the rows the user
means are mostly rows the browser has never been sent. So a selection is not a
list of ids. It is one of two statements about the query:
type SelectionModel =
/** Rows the user picked one at a time. The empty array is "nothing". */
| { mode: "ids"; ids: readonly string[] }
/** Everything the query matches, minus the rows the user unticked. */
| { mode: "all-matching"; excluded: readonly string[] }Neither mode ever converts into the other. Untick every row of a page
while in all-matching and you still have "everything except these fifty",
which is the correct reading of what you did — not "nothing", which would
discard a selection of 25 000 in response to fifty clicks. Tick every row of a
page one at a time and you have those fifty, not everything: you never said
everything.
The count. In ids it is ids.length. In all-matching it is
rowCount - excluded.length, and it is undefined until your server has
answered with a rowCount — the table says so rather than showing a number
it cannot know. The header checkbox is named "Select all rows" in that window,
with no figure in it.
scope: "page" — when your backend acts on ids only
Use it when your backend has no bulk-by-query endpoint: every write is one
row by id, and there is no call you could translate all-matching into. For
such a host the default header checkbox is a promise you cannot keep — the user
ticks it, reads "all matching rows selected", and you are holding 50 ids out of
5 000.
features: { selection: { scope: "page" } }true is exactly { scope: "all-matching" }, the default and everything
described above. { scope: "page" } changes one control and nothing else:
- The header checkbox ticks the selectable rows of the current page —
group headers are not among them — and produces
{ mode: "ids", ids }. It is checked when every one of them is selected, indeterminate when some are, unchecked when none are, and unchecked on a page with nothing selectable on it at all. - Ids survive a page turn, so a selection is built across pages one page at a time: tick page 1, go to page 2 — where the header is unchecked, because nothing here is selected — tick it, and you hold both pages. Unticking the header on page 2 takes page 2 back out and leaves page 1 alone.
all-matchingis unreachable. No control and no hook action produces it. A model that arrives in that mode anyway — you movedscopeunder a live selection — is read as no selection and said once in development, rather than silently honoured.- The count and the bulk-action bar read
ids.length, so nothing here waits for arowCount, not even in a grouped table where the default scope has to go quiet. - The header checkbox is named "Select all rows on this page"
(
labels.selectAllRowsOnPage), because a control that reaches one page must not say the sentence the other one says.
Everything else is unchanged, including the rule below.
A change to the query clears the selection
The filters, the search and the grouping (including which groups are open). Not the sorting, and not the page.
This is the rule that makes the feature safe rather than dangerous.
all-matching is defined relative to a query; let the query move under it and
a selection of 25 000 silently becomes a selection of 90 000 with no gesture
from the user — and then your bulk action runs on them. Sorting and paging
change no row's membership, only the order and which slice is on screen, so
clearing there would make the feature useless for the case it exists for.
The cleared selection is published, not merely emptied: onSelectionChange
fires with { mode: "ids", ids: [] } and the new query. A model only the table
knows it has dropped is the same defect one layer up.
What you receive
onSelectionChange?: (selection: {
mode: "ids" | "all-matching"
ids?: readonly string[]
excluded?: readonly string[]
/** The query the selection is relative to. Meaningless without it. */
query: TableQuery
/** Undefined until a server has answered. */
count: number | undefined
}) => voidThe query is not optional. { mode: "all-matching", excluded: [] } names
no row on its own; the filters it was drawn against are what turn it into
rows. It is not called on mount — nothing is selected there, and
onQueryChange has already announced the query.
The SQL
An ids selection is the rows it names, and nothing else — not the filters as
well. The user named those records; a filter applied since cannot un-name one.
UPDATE receipts SET flagged = true WHERE id = ANY($1) -- $1 = idsAn all-matching selection is the query's own predicate — the same filters
and search the page was built from — minus what the
user took back out:
UPDATE receipts SET flagged = true
WHERE <the filters and the search, exactly as in "Filters on the wire">
AND id NOT IN (<excluded>)The grouping, the sorting and the pagination play no part. None of them changes which rows match — only how they are presented — and a bulk action acts on the rows. The pagination in particular: the whole point is that the user ticked the header, not the page they could see.
Answer with how many rows you actually changed, not how many were selected. It is the number worth showing afterwards, and it is the only thing that ever reveals a client-side count that had drifted from yours.
The bulk-action slot
<DataTable
instance={table}
renderSelectionActions={({ mode, ids, excluded, query, count, clear }) => (
<>
<strong>{count === undefined ? "All matching rows" : `${count} rows`} selected</strong>
<button onClick={async () => {
await api.flag({ mode, ids, excluded, query })
clear()
refetch()
}}>
Flag these
</button>
<button onClick={clear}>Cancel</button>
</>
)}
/>Rendered only while something is selected, in a bar of its own above the
table. That is the whole difference from toolbarContent, which is a static
ReactNode and knows nothing about a selection; a bar you want on screen
always goes there instead. clear rides along with the model so a Cancel
button does not have to reach back into the instance.
The bar appears once, when the first row is ticked, and goes once, when the
last is unticked — it does not come and go as the count changes, so the table
is not pushed down and back on every click. Its entrance animates transform
and opacity only, and stops animating under prefers-reduced-motion.
The rest of it
getRowIdis effectively required. Without it TanStack keys rows by position, and a selection then follows the slot rather than the record: sort the table — which deliberately does not clear the selection — and the ticks stay on rows three to seven while three to seven are now different records. The table says so once in development, in both modes.A selection is never written to
storage.pruneLayoutis a whitelist and rebuilds a stored layout from the keys it recognises, so nothing about a selection can survive a reload. One restored from last week, against a query that has since changed, is the query-change failure wearing a hat.Group rows are not selectable in this version. A group stands for children the browser does not hold — in server mode most of them have never been sent — so a tick on one could only mean "every row under this group", which is a third mode no backend could answer without a path predicate the wire does not carry. Its checkbox cell is kept and left empty.
The column is chrome, not data. It leads the table, before the row numbers and before the grouped column; pinned to the start and not unpinnable; not sortable, filterable, groupable, editable, hideable, reorderable or resizable; and absent from the Columns panel.
Both new strings are
DataTableLabels.selectRow(rowNumber)names a row's own box — the number is the only thing telling one from another — andselectAllRows(count, raw)names the header's, including the branch with no count in it at all.Counting with a grouping on:
rowCountin a grouped server table is the length of the flattened list, group headers included, so it is not a count of records. The selection itself is unaffected —all-matchingmeans the query's filters minus the exclusions, which yourWHEREresolves to records either way — but the number the header checkbox would speak is not the number the user is about to act on. So it does not speak one: a grouped table says "select all rows" without a count.Pass
selectableRowCountto say it properly:useDataTable({ rowCount: page.total, // flattened: what the pager measures selectableRowCount: page.records, // records: what a selection counts })Do not put a leaf count in
rowCountinstead.pageCountis derived from it, so a smaller number there silently takes pages away from the user.
Your own buttons in the toolbar
Two slots, one at each edge:
<DataTable
instance={table}
toolbarContent={<ViewPicker />} {/* beside the search */}
toolbarActions={<button onClick={exportRows}>Export</button>} {/* far right */}
/>toolbarContent sits at the leading edge, next to the quick search;
toolbarActions sits at the trailing edge, which is where an Export or a
"New" button usually belongs. Neither is rendered when toolbar={false} — the
host owns the row entirely in that case, and QuickSearch, TablePagination,
StatusBar, TotalsFooter and TableSideBar are all exported so it can be
rebuilt piece by piece.
Exporting
There is no built-in CSV or Excel export, and that is a decision rather than a gap. In server mode the browser holds one page; a client-side export would write out the fifty rows on screen and call the file "the table", which is the same lie that made row grouping server-side — and the kind a user cannot see. Export belongs to the endpoint that holds the rows.
What the table gives you is the state to send it. instance.query is the
current TableQuery — the filters, the search, the sorting and the grouping,
exactly as they are on screen:
async function exportRows() {
const response = await fetch("/api/receipts/export", {
method: "POST",
headers: { "Content-Type": "application/json" },
// Drop `pagination`: an export wants every matching row, not this page.
body: JSON.stringify({ ...table.query, pagination: undefined }),
})
// …hand the response to the browser as a download
}With row selection on, send what onSelectionChange last
gave you instead, and your endpoint exports exactly the rows the checkboxes
mean — including "everything matching, except these", which no list of ids
could have expressed.
Row numbers
Off by default, like row selection and the
status bar. Every other flag turns OFF something the table has
always done, so true is what you already had; these add a column (or a band),
and a table that grew one on a minor upgrade would be a breaking change dressed
as a small one.
useDataTable({ id: "receipts", data, columns, features: { rowNumbers: true } })The number is the row's 1-based place in the whole result set —
pageIndex * pageSize + indexOnPage + 1 — and not its place on the page. A
count that restarted at 1 on page 2 of a hundred thousand rows would tell the
user nothing they did not already know, and in server mode the page offset is
the one thing the client always has.
Group rows are numbered too. This is not what AG Grid does, and the
difference is deliberate: numbering only the records needs to know how many
group headers come before this page, and no field on the wire carries that —
a page is a window, and the client cannot count what it has never been sent.
Numbering every flattened row is computable from the offset alone and is
truthful about position in the list on screen. The one row with no number is
the "continued" header the table draws itself from
startPath: it was never in the server's answer, so giving
it one would put every row after a page boundary out by one. Its cell is
there and empty.
The column is chrome, not a column of data, and that decides the rest:
- It leads everything the host declared, the grouped column included — and follows the selection column when both are on.
- Pinned to the start, and not unpinnable.
- Not sortable, filterable, groupable, editable, hideable or reorderable, and absent from the Columns panel — a tick that could remove it would contradict the flag that put it there.
- Resizable, and the width you drag it to is saved in the layout like any
other column width. Its default width is as wide as the largest number the
current
rowCountcan print. - Its header is empty to the eye and named for a screen reader through the
rowNumberlabel, so translate that one alongside the rest.
Status bar
Off by default, for the same reason row numbers is: every
other flag turns OFF something the table has always done, so true is what
you already had, while this one adds a band nobody asked for.
useDataTable({ id: "receipts", data, columns, features: { statusBar: true } })Why it is not the footer. The footer already prints Rows: 100 000
beside the page controls — that is navigation. The status bar is content: how
many rows, whether a filter is narrowing them, what they are grouped by. A
band that repeated a number already on screen would be worse than no band, so
turning it on moves the row count OUT of the footer and into the bar; with it
off the footer is exactly what it has always been. Both surfaces read the same
instance.flags.statusBar to agree on which of them owns the count, so a
shell built on useDataTable and <TablePagination> alone gets the hand-off
for free the moment it flips the flag — no prop of its own to keep in sync.
What it says, as separate parts rather than one sentence, so a translation can put a different one first:
Total rows —
rowCount, formatted the same way the footer's own total is, so the two never disagree about what a thousand looks like.Filtered, instead of total, the moment a column filter or the quick search is narrowing the result (
instance.filtering.isFiltered). To say "X of Y" the server has to tell you Y, because a server-side filter means the client never sees the wider set — passunfilteredTotalalongsiderowCount:useDataTable({ mode: "server", data: page.rows, rowCount: page.total, unfilteredTotal: page.unfilteredTotal, // optional … })Absent is the graceful case, not a hole. A backend that has not implemented the extra count yet leaves the bar stating only how many rows matched, which is the honest reading of "unknown". In client mode you never need this option at all:
datathere already IS the whole set the table filters, so the table readsdata.lengthfor itself.Grouped by, while row grouping is active — the columns being grouped by, in order, using the same names the Row Groups chips already speak.
A host slot. Pass a
ReactNodeinstead oftrueto keep the band but add content of your own at its end — something this library has no business knowing, the waytoolbarContentextends the toolbar. Same flag, two shapes, one decision.
Deliberately not in this version: a selected-row count, although row selection now exists — the count belongs beside the action it is about to be used for, and that is the bulk-action bar, in your own words. Repeating it down here would put the same number on screen twice, which is the thing this band exists to stop the footer doing. Nor an aggregate like a sum or an average — summing one SERVER page of a filtered result would be wrong in the exact way client-side grouping would have been, a partial answer presented as the whole one. See Totals footer for the honest version: you answer the query for your own total, and hand the finished number over.
Shape. A role="status" region with aria-live="polite": its whole job
is to report a change you caused elsewhere, and polite is what keeps a row
count from interrupting whatever a screen reader is already reading.
Totals footer
<DataTable instance={table} totals={{ amount: formatMoney(page.amountTotal) }} />A row under the body, aligned with the columns, holding whatever you put under each column id — and nothing under the rest.
This library computes none of it. Summing the fifty rows of a page and
presenting it as the table's total is the same lie that forced
row grouping to be server-side: a filtered or paged result
would print a number describing only what happens to be rendered, and it is
a lie the user cannot see. Answer the query for your own total — the same
query onQueryChange already hands you — and pass the finished ReactNode.
This was the author's explicit choice over a wire field the table would fetch
on its own: a field is one more thing every backend has to implement before
the feature works at all, and a prop is not.
It sticks to the bottom of the viewport, the way the header sticks to the
top — stickyHeader's own mechanism, mirrored rather than reinvented, with
no prop of its own to turn it off: a total that scrolls out of view is a
total nobody reads.
It respects pinning. A total under a column pinned to either edge sticks
with it, at the exact column.getStart()/getAfter() offset the body's own
pinned cells use — the same pinnedStyle helper, not a second copy of the
arithmetic.
It respects the column's own alignment, so money lands under money — with
no alignment concept of its own to get wrong. Each cell is a plain .dt-td,
the same box model (padding, height) a body cell renders with, so a
right-aligned <span> you already use in the Amount column's cell renderer
comes out aligned identically here.
The leading cell carries a caption — labels.totalsRow, translated in
all three shipped sets — beside whatever total you gave that same column, if
any. The row itself carries the same string as its aria-label, so a screen
reader does not read it as one more record.
totals={{}} still draws the row, with only the caption in it — totals
being absent is the only thing that renders no <tfoot> at all. This is
deliberate: a total that only your server can answer is not known on the
first render, and a row that popped into existence the instant the answer
landed would shift the body down by a row's height at an arbitrary moment.
Pass {} the moment the feature is turned on and fill it in once the answer
arrives, the way the playground's own Totals row toggle does.
A totals key naming a column that does not exist, or one that is hidden,
changes nothing: the row is built by walking the table's own rendered
columns, not by walking totals' keys, so an unmatched entry is simply never
read.
Timeline (Gantt)
const table = useDataTable({
id: "stages",
data: departments,
columns,
getSubRows: (row) => row.children,
timeline: {
start: "2026-08-31",
end: "2026-12-07",
zoom: "week", // "day" | "week" | "month"
scrollTo: today,
getItems: (row) => [
{ kind: "bar", variant: "plan", start: row.planStart, end: row.planEnd },
{ kind: "bar", variant: "actual", start: row.started, end: row.finished ?? today, progress: row.percent },
],
markers: [
{ date: today, label: `Today ${short(today)}`, tone: "danger" },
{ date: deadline, label: `Deadline ${short(deadline)}`, tone: "neutral", dashed: true, at: "end" },
],
},
})
<DataTable instance={table} getRowTone={(row) => (row.level === 0 ? "strong" : row.level === 1 ? "soft" : undefined)} />A time scale after your columns: each row draws bars and points on it, under markers that run the height of the body.
It is one more column, not a second component beside the table. The pane is appended after your columns, unpinned, so a column you pinned stays put while the timeline scrolls under it — and the tree, virtual rows, row heights, the totals row and detail panels line up with it because they are the tab
