Inline editing
Mark a column editable and its cells open an editor on Enter, F2 or a
double-click. The editor comes from the column’s kind, so a number column gets a number input and
an enum column gets a select without you saying so. Your save handler runs when the value is
committed; throw from it and the editor stays open with the message.
| Olivia Khan | 4 | Pro | Active | — | |
| Elias Reddy | 18 | Team | Invited | — | |
| Kavya Patel | 1 | Free | Active | Speaks at meetups. | |
| Diya Das | 1 | Free | Active | — | |
| Yuki Kim | 63 | Enterprise | Active | Renewal in Q1. | |
| Vihaan Reddy | 14 | Team | Active | Asked about invoices. |
Edit log
Double-click a cell, or focus one and press Enter. Try 0 seats, or the Enterprise plan.
import * as React from "react";
import { DataGrid, createDataGridColumns, useDataGrid } from "@/components/ui/data-grid";
import { makePeople, PLAN_OPTIONS, STATUS_OPTIONS, type Person,} from "@/lib/data-grid-demo-data";
const col = createDataGridColumns<Person>();
const columns = col.columns([ col.text("name", { header: "Name", editable: { placeholder: "Full name", validate: (v) => (v.trim() ? null : "A name is required"), }, }), col.number("seats", { header: "Seats", // min/max/step configure the input; only `validate` can reject a value. editable: { min: 1, max: 500, step: 1, validate: (v) => !Number.isFinite(v) ? "Seats are required" : v < 1 || v > 500 ? "Between 1 and 500" : Number.isInteger(v) ? null : "Whole seats only", }, }), col.enum("plan", { header: "Plan", options: PLAN_OPTIONS, editable: true, // The column's own handler runs first; throwing keeps the editor open with the message. onEdit: async ({ value }) => { await new Promise((r) => setTimeout(r, 300)); if (value === "enterprise") throw new Error("Enterprise plans are set by sales"); }, }), col.enum("status", { header: "Status", options: STATUS_OPTIONS }), col.boolean("active", { header: "Active", editable: true }), col.text("notes", { header: "Notes", size: 240, editable: { type: "textarea", placeholder: "Add a note…" }, }),]);
/** Text, number, select, checkbox and textarea editors, with validation and an edit log. */export default function DataGridEditing() { const [data, setData] = React.useState(() => makePeople(40, 21)); const [log, setLog] = React.useState<string[]>([]); const grid = useDataGrid<Person>({ name: "editing", data, columns, getRowId: (r) => r.id, pagination: { pageSize: 6 }, onCellEdit: ({ rowId, columnId, value, previous, row }) => { setData((prev) => prev.map((p) => (p.id === rowId ? { ...p, [columnId]: value } : p))); setLog((prev) => [`${row.name} · ${columnId}: ${String(previous)} → ${String(value)}`, ...prev].slice(0, 5), ); }, }); return ( <div className="flex flex-col gap-3"> <DataGrid grid={grid} /> <div className="rounded-lg border bg-muted/30 px-3 py-2 text-sm"> <p className="font-medium">Edit log</p> {log.length ? ( <ul className="mt-1 flex flex-col gap-0.5 text-muted-foreground"> {log.map((line, i) => ( <li key={i}>{line}</li> ))} </ul> ) : ( <p className="mt-1 text-muted-foreground"> Double-click a cell, or focus one and press Enter. Try 0 seats, or the Enterprise plan. </p> )} </div> </div> );}Use it
Section titled “Use it”Set editable on the columns that may change, and give the grid a handler for committed edits.
import { createDataGridColumns } from "@/components/ui/data-grid"
const col = createDataGridColumns<Customer>()
export const columns = col.columns([ col.text("name", { header: "Name", editable: true }), col.number("seats", { header: "Seats", editable: { min: 1, step: 1 } }), col.enum("plan", { header: "Plan", options: PLAN_OPTIONS, editable: true }), col.boolean("active", { header: "Active", editable: true }), col.text("notes", { header: "Notes", editable: { type: "textarea" } }),])<DataGrid data={customers} columns={columns} getRowId={(c) => c.id} onCellEdit={async ({ row, columnId, value }) => { await api.customers.update(row.id, { [columnId]: value }) }}/>editable: true takes the defaults for the kind. An object configures the editor; its fields are
in the editor spec below.
Editors by kind
Section titled “Editors by kind”| Column kind | Editor | Commits |
|---|---|---|
text, link, tags, custom |
text input | the string |
number, currency, percent |
<input type="number"> with inputMode="decimal" |
Number(raw); an empty field commits null; anything that is not a finite number commits the raw string |
enum |
select, opened at once, listing the column’s options |
the option’s value, as soon as one is chosen |
boolean |
checkbox | the new boolean, as soon as it is toggled |
date |
<input type="date"> |
a Date; an empty field commits null; an unparseable value commits the raw string |
datetime |
<input type="datetime-local"> |
a Date, same rules |
any kind with type: "textarea" |
three-row textarea | the string, with line breaks |
The editor type can be set explicitly on any column; type: "select" on a text column needs
options. parse replaces the default conversion for text, number, date and datetime editors.
How it behaves
Section titled “How it behaves”Opening. Enter or F2 on the focused cell, or a double-click on any
editable cell. Cells that can be edited show a text cursor on hover and carry data-editable. A
grouped row is never editable. Programmatically: grid.editing.start(rowId, columnId).
Committing. Enter commits and moves the focus to the cell below; Shift+Enter commits and stays (in a textarea it inserts a line break instead). Tab commits and moves right, Shift+Tab commits and moves left; both open the next editor only when you press Enter again. Clicking anywhere else blurs the input and commits. Choosing an option in a select commits it and moves down; toggling a checkbox commits at once.
Cancelling. Escape closes the editor and keeps the old value. Closing a select without choosing cancels, and so does clicking away from an open checkbox.
Unchanged values. If the committed value Object.is the previous one, the editor closes and no
handler runs.
Validation. validate(value, row) runs first. Return a string to reject the value: the
editor stays open, the message appears as its tooltip, the input gets aria-invalid and a red
ring. Return nothing to accept.
Saving. The column’s onEdit runs, then the grid’s onCellEdit, each awaited in turn. The
editor stays open until both settle. If either throws, the message (error.message, or
labels.invalid for anything that is not an Error) is shown the same way as a validation error
and the value is not committed. Resolve normally and the editor closes and focus returns to the
cell.
interface DataGridCellEdit<TData, TValue> { rowId: string // from getRowId rowIndex: number // the row's index in the data row: TData // the row as it was before the edit columnId: string value: TValue // the parsed new value previous: TValue // what the cell held}On a column, onEdit is typed with that column’s value type. On the grid, onCellEdit receives
DataGridCellEdit<TData, unknown>: narrow on columnId (type safety).
Local state or refetch
Section titled “Local state or refetch”The grid never writes to your data. After a commit you either update the array you pass as data
(optimistic) or refetch it from the server; until you do, the cell shows the old value.
// Optimistic: update local state, let the request follow.onCellEdit={({ rowId, columnId, value }) => { setRows((rows) => rows.map((r) => (r.id === rowId ? { ...r, [columnId]: value } : r))) void api.update(rowId, { [columnId]: value })}}
// Pessimistic: wait for the server, then invalidate the query. A failure keeps the editor open.onCellEdit={async ({ rowId, columnId, value }) => { await api.update(rowId, { [columnId]: value }) await queryClient.invalidateQueries({ queryKey: ["customers"] })}}With server-side data the second shape is the natural one.
Limiting what can be edited
Section titled “Limiting what can be edited”editing: falseturns editing off for the whole grid, whatever the columns say.editing: { canEdit: (row) => row.status !== "locked" }is a per-row switch for every column.editable: { canEdit: (row) => row.draft }is a per-row switch for one column.editable: false, or leaving it out, keeps a column read-only.
Accessibility
Section titled “Accessibility”The editor is labelled Edit: <column label> and marked aria-invalid when rejected. Once any
column is editable, every cell that cannot be edited carries aria-readonly="true"; a grid with no
editable column has no such attributes. Focus returns to the grid cell when the editor closes; see
keyboard navigation for the full model.
The editor spec
Section titled “The editor spec”editable: true | DataGridEditorSpec<TValue, TData>
| Field | Type | Default | What it does |
|---|---|---|---|
type |
"text" | "number" | "select" | "checkbox" | "date" | "datetime" | "textarea" |
from the kind | Which editor opens. |
options |
readonly DataGridOption<TValue>[] |
the column’s options |
Choices for a select editor. |
min, max, step |
number |
— | Attributes on a number input; min and max are enforced on commit. |
placeholder |
string |
— | Placeholder for text, number, date and textarea inputs. |
validate |
(value: TValue, row: TData) => string | null | undefined | void |
— | A string rejects the value and is shown. |
parse |
(raw: string, row: TData) => TValue |
by type | Turns the input’s string into the column’s value. |
canEdit |
(row: TData) => boolean |
— | Per-row switch for this column. |
Options
Section titled “Options”Column options:
| Option | Type | Default | What it does |
|---|---|---|---|
editable |
boolean | DataGridEditorSpec |
false |
Lets the cells open an editor. |
onEdit |
(edit: DataGridCellEdit<TData, TValue>) => void | Promise<void> |
— | Runs after a commit in this column, before the grid’s handler. Throw to reject. |
Grid options:
| Option | Type | Default | What it does |
|---|---|---|---|
editing |
boolean | { onCellEdit?, canEdit? } |
on | false disables editing everywhere. |
onCellEdit |
(edit: DataGridCellEdit<TData, unknown>) => void | Promise<void> |
— | Runs after any commit, after the column’s onEdit. Throw to reject. |
The editing api
Section titled “The editing api”grid.editing on the object useDataGrid returns:
| Member | What it does |
|---|---|
enabled |
false only with editing: false. |
cell |
{ rowId, columnId } of the open editor, or null. |
error |
The current validation or save message, or null. |
start(rowId, columnId) |
Opens an editor. |
cancel() |
Closes it without committing. |
commit(value) |
Validates, runs the handlers, resolves true on success. |
isEditable(cell) |
Whether a cell would open an editor. |
editorFor(cell) |
The resolved spec (type and options filled in), or null. |
See also
Section titled “See also”- Keyboard navigation: every key, including the ones inside editors.
- Columns: kinds, options and formatting.
- Type safety: how
validateandonEditknow the column’s value type.