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-grid

Quick 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 what onCellEdit/onRowDelete hand 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. A ColumnDef without meta.type renders through its normal TanStack cell definition 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). boolean has 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 to onCellEdit. Out of the box this only checks "required" and "must be a number" for number/currency — swap in your own rules by validating inside onCellEdit and throwing.
  • Async commitsonCellEdit is 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.

KeyEffect
Arrow keysMove focus between cells
Tab / Shift+TabMove and wrap at row ends (spreadsheet convention, not standard APG behavior)
Enter / Shift+EnterMove down/up one row without entering edit mode
F2Enter edit mode, preserving the current value
EscapeCancel the in-progress edit
Shift+Click / Shift+ArrowUp / Shift+ArrowDownExtend 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:

  • onRowAdd renders 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 exported getDataGridRowDefaults(table), which seeds a value per column from its meta.type ("" for text/date, 0 for number/currency, false for boolean, the first option for select, [] for multiSelect, null for user).
  • onRowDelete renders 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 (and state.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.

TitleEstimate (h)

API Reference

PropTypeDefaultDescription
tableTable<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 a meta.type render read-only through their normal TanStack cell definition — this is by design (e.g. for a computed or actions column), not a bug.
  • meta: { type: "text" } fails to typecheck. data-grid-types's declare module augmentation has to be part of your TypeScript program. Installing the file (CLI or manual) is enough — but if you git rm it or exclude it from include in tsconfig.json, the augmentation stops applying.
  • Deleting or editing a row updates the wrong record. You didn't pass getRowId to useReactTable(). TanStack defaults row ids to array index, so any add/delete/reorder shifts which row an id points to. Always pass a stable getRowId: (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 onRowAdd but nothing focuses after adding a row. The grid detects a successful add by watching rows.length grow after your state update lands. If your onRowAdd doesn'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 table instance's own in-memory state, not anything persisted. Wire onColumnLayoutChange and feed the saved layout back into useReactTable()'s state/initialState yourself.