Data Grid
An editable grid over a consumer-owned useReactTable() instance — every cell renders its live control, borderless at rest, with a full spreadsheet keyboard model over role="grid".
DataGrid renders a TanStack Table instance
you create and own. It never holds data itself — it reads from your
table, and reports every edit, add, and delete back to you through
callbacks. You decide how (and whether) to persist them.
Installation
pnpm dlx shadcn@latest add @syncblocks/data-gridQuick start
The smallest working grid: two columns, one meta.type each, and an
onCellEdit that writes the new value back into state.
"use client"
import * as React from "react"
import {
type ColumnDef,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import { DataGrid } from "@/components/ui/data-grid"
// side-effect import — augments ColumnMeta so `meta: { type: "text" }` typechecks
import "@/components/ui/data-grid-types"
interface Contact {
id: string
name: string
email: string
}
const columns: ColumnDef<Contact>[] = [
{ accessorKey: "name", header: "Name", meta: { type: "text" } },
{ accessorKey: "email", header: "Email", meta: { type: "text" } },
]
export function ContactsGrid() {
const [data, setData] = React.useState<Contact[]>([
{ id: "1", name: "Ada Lovelace", email: "ada@example.com" },
{ id: "2", name: "Grace Hopper", email: "grace@example.com" },
])
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
})
return (
<DataGrid
table={table}
onCellEdit={(rowId, columnId, value) => {
setData((prev) =>
prev.map((row) =>
row.id === rowId ? { ...row, [columnId]: value } : row
)
)
}}
/>
)
}Two things to get right before the grid does anything useful:
getRowId: (row) => row.id— without it, TanStack falls back to the row's array index as its id. That id is whatonCellEdit/onRowDeletehand back to you, so a deleted or reordered row will silently target the wrong record. Always pass a stable id.meta: { type: ... }on a column — this is what turns a column editable. AColumnDefwithoutmeta.typerenders through its normal TanStackcelldefinition and is read-only; clicking it does nothing.
Column types
Eight column types are available, each with a matching cell editor:
text, number, currency, select, boolean, date, multiSelect,
user. multiSelect and user render a shared popover picker (chips or an
avatar, opening a searchable list); the rest are plain inline controls.
number and currency render right-aligned in tabular figures, header
included, so decimal points line up down the column and the label sits over
its values. Every other type stays left-aligned.
Full field reference (options, users, maxChips, summary) lives in
Data Grid Types — install it once as a
registryDependencies entry, no separate import needed beyond the
side-effect one shown above.
Editing behavior
- Enter edit mode — click a cell, focus it and press F2
(preserves the existing value), or focus it and type a character (replaces
the value, seeded with what you typed).
booleanhas no separate edit mode: click, F2, or Space toggles and commits in one step. - Commit — on blur or Enter. Escape discards the in-progress value and returns focus to the cell.
- Validation — runs on every keystroke, shown as red text under the cell
and wired through
aria-invalid/aria-describedby. An invalid value is never sent toonCellEdit. Out of the box this only checks "required" and "must be a number" fornumber/currency— swap in your own rules by validating insideonCellEditand throwing. - Async commits —
onCellEditis always awaited, and a synchronous throw is treated the same as a rejected promise. While pending, the control shows a spinner and disables. On rejection the typed value stays on screen (it never reverts) and renders with the same invalid-cell styling; committing the same value again retries it, since dedup compares against the last successful commit, not the last attempted one.
Keyboard shortcuts
The grid implements the full WAI-ARIA grid keyboard model, with one deliberate divergence noted below.
| Key | Effect |
|---|---|
| Arrow keys | Move focus between cells |
| Tab / Shift+Tab | Move and wrap at row ends (spreadsheet convention, not standard APG behavior) |
| Enter / Shift+Enter | Move down/up one row without entering edit mode |
| F2 | Enter edit mode, preserving the current value |
| Escape | Cancel the in-progress edit |
| Shift+Click / Shift+ArrowUp / Shift+ArrowDown | Extend row selection from the last plain click or move |
Exactly one gridcell sits in the page's tab order at a time (roving tabindex) — tabbing into the grid from elsewhere always lands on that cell, not on every cell in it.
Rows
Add and delete are both opt-in — pass onRowAdd/onRowDelete and the
grid renders the affordances for you:
onRowAddrenders a trailing "+ New row" control and a hover "+" between rows to insert after a specific one. Since the grid never owns data, build the new row yourself with the exportedgetDataGridRowDefaults(table), which seeds a value per column from itsmeta.type(""for text/date,0for number/currency,falsefor boolean, the first option for select,[]for multiSelect,nullfor user).onRowDeleterenders a hover kebab menu with a "Delete row" item that fires only after a built-in confirmation dialog is accepted — no optimistic removal, no undo.
Row selection is on by default — TanStack's own row-selection feature is
enabled unless you set enableRowSelection: false. Pass
state: { rowSelection } and onRowSelectionChange to read the selection
outside the grid (needed for a count or a bulk-action bar). There's no
onBulkAction prop; compose your own bar from
BulkToolbar and loop your selected ids
through the same onRowDelete(rowId) for bulk delete — see
Row selection & bulk delete below.
Toolbar
Above the grid sits a two-row toolbar: a global search box on the left with the Filter and Columns buttons on the right, then a row of active filter chips with a Clear filters action.
Search and Filter only appear when you wire getFilteredRowModel — without it
both would be no-ops, so they stay hidden (the same rule the sort affordance
follows). For search, also set globalFilterFn: dataGridGlobalFilterFn and
getColumnCanGlobalFilter: dataGridGetColumnCanGlobalFilter from
DataSearch; for filtering, set
filterFn: dataGridFilterFn from
DataFilter on each filterable column.
Below 448px the grid switches to cards (see
Responsive behavior) and the toolbar collapses: search
stays inline, and a single badge-counted Options button opens one bottom
sheet stacking Sort, Filters, and Visible fields — cards have no
header row to hang sorting or column visibility off. The Sort section needs
getSortedRowModel for the same reason the header affordance does.
Columns
Resize, sort, pin, and reorder all run on TanStack's own column state
(columnSizing/sorting/columnPinning/columnOrder), so they work with
zero setup on useReactTable():
- Resize — drag a column's right border; double-click it to reset to the default width.
- Sort — click a column header to cycle ascending → descending →
unsorted; the arrow icon reflects the current direction. The affordance only
appears when you wire
getSortedRowModel(andstate.sorting/onSortingChange) — without it a click would be a no-op, so it stays hidden. - Column menu — each header has a
⋯button (hover or focus to reveal) with Move left/right, Hide column, and the Pin column cycle (unpinned → left → right → unpinned). Pinned columns stick during horizontal scroll. - Reorder — besides Move left/right, the toolbar's "Columns" button opens a panel to drag columns into a new order and restore hidden ones.
Pass onColumnLayoutChange to observe { columnOrder, columnSizing, columnPinning } and persist it — it's a reporting side-channel only;
omitting it doesn't disable resize/pin/reorder.
Responsive behavior
Below a 448px measured container width (a ResizeObserver on the grid's
own wrapper, never a viewport media query — the driving case is a workspace
pane getting narrower, not a phone), the grid swaps to a card list:
each row becomes a card (title + up to two subtitle fields, positional by
column order unless you mark columns with meta.summary), and tapping one
opens a bottom drawer with every column as a labeled field, committing
through the same onCellEdit. Pass narrowFallback="none" to keep the real
grid at any width instead, with the first column pinned sticky-left.
Examples
Basic editing
A minimal grid: text and number columns, no row add/delete, no
selection.
Title | Estimate (h) |
|---|---|
All column types
Every meta.type in one grid — text, number, currency, select,
boolean, date, multiSelect, and user.
Name | Email | Seats | MRR | Plan | Active | Renews on | Tags | Owner |
|---|---|---|---|---|---|---|---|---|
Row add & delete
Pass onRowAdd/onRowDelete to get the trailing "+ New row" control, the
hover between-row "+", and a hover kebab menu with a confirm-guarded
"Delete row".
Title | Estimate (h) | |
|---|---|---|
Row selection & bulk delete
Row selection is on by default. Read table.getState().rowSelection to
drive a BulkToolbar, and loop the
selected ids through the same onRowDelete(rowId) for a bulk action.
Title | Estimate (h) | |
|---|---|---|
Read-only & editable columns mixed
A column without meta.type (Order #) renders through its normal
TanStack cell definition and stays read-only, sitting next to editable
columns in the same grid.
Order # | Customer | Status |
|---|---|---|
#1001 | ||
#1002 |
Narrow container (card view)
Below a 448px measured container width, the grid swaps to a card list with a bottom-drawer editor — forced here with a fixed-width wrapper instead of resizing the browser.
Title | Estimate (h) |
|---|---|
Empty state
Pass emptyState a composed shadcn Empty for an icon, title, description,
and action instead of the default "No results." text.
Title | Estimate (h) | |
|---|---|---|
No tasks yet Add your first task to start tracking estimates. | ||
Loading state
The grid never owns data, so it never owns "data isn't here yet" either —
render a Skeleton-based table with matching headers while loading, then
mount DataGrid once data arrives.
| Title | Estimate (h) |
|---|---|
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
table | Table<TData> | - | A useReactTable() instance you create and own. The grid renders from it and never mutates it directly. |
onCellEdit | (rowId: string, columnId: string, value: unknown) => void | Promise<void> | - | Called with a valid, committed edit. rowId matches TanStack's own Row.id; narrow value using columnId and that column's meta.type. |
onRowAdd? | (afterRowId?: string) => void | Promise<void> | - | Renders the "+ New row" and between-row "+" affordances. Omit afterRowId to append; pass it to insert after that row. Both entry points autofocus the new row's first cell once it appears — build the row itself with getDataGridRowDefaults(). |
onRowDelete? | (rowId: string) => void | Promise<void> | - | Renders a hover kebab menu in a trailing column with a destructive "Delete row" item. Fires only after the built-in confirmation dialog is accepted — there's no optimistic removal or undo. |
onColumnLayoutChange? | (layout: DataGridColumnLayout) => void | - | Fires after a resize, pin, or reorder change with the table's current { columnOrder, columnSizing, columnPinning }. Purely a reporting side-channel — resize/pin/reorder mutate the table instance's own state directly and work with no callback at all. |
narrowFallback? | "cards" | "none" | "cards" | Below a 448px measured container width (never the viewport), "cards" swaps the grid for a card list with a swipeable bottom-drawer editor. "none" is the escape hatch: keeps the real grid at any width, with the first column stuck sticky-left. |
emptyState? | React.ReactNode | "No results." | Rendered in place of rows when table.getRowModel().rows is empty, in both the table and card layouts. Pass a composed shadcn Empty for an icon/title/action instead of plain text. |
className? | string | - | Additional CSS classes applied to the root element. |
Troubleshooting
- A cell doesn't enter edit mode when I click it. Its column is missing
meta: { type: ... }. Columns without ameta.typerender read-only through their normal TanStackcelldefinition — this is by design (e.g. for a computed or actions column), not a bug. meta: { type: "text" }fails to typecheck.data-grid-types'sdeclare moduleaugmentation has to be part of your TypeScript program. Installing the file (CLI or manual) is enough — but if yougit rmit or exclude it fromincludeintsconfig.json, the augmentation stops applying.- Deleting or editing a row updates the wrong record. You didn't pass
getRowIdtouseReactTable(). TanStack defaults row ids to array index, so any add/delete/reorder shifts which row an id points to. Always pass a stablegetRowId: (row) => row.id. - The grid stays stuck on a spinner forever.
onCellEdit's returned promise never resolved or rejected. The grid has no timeout — it awaits indefinitely, matching the "never silently drop an edit" contract. - I passed
onRowAddbut nothing focuses after adding a row. The grid detects a successful add by watchingrows.lengthgrow after your state update lands. If youronRowAdddoesn't synchronously (or eventually) increase the row count — e.g. it's gated behind a confirmation the user hasn't accepted yet — the autofocus never fires. That's expected; it's not polling for some other signal. - Resize/pin/reorder don't persist across a reload. They mutate the
tableinstance's own in-memory state, not anything persisted. WireonColumnLayoutChangeand feed the saved layout back intouseReactTable()'sstate/initialStateyourself.