gridcn1.3.0

Editing & cell types

How editing works, the built-in cell types, and how to add your own.

Edit activation

A single click never starts editing. It only moves the active cell. Editing starts only through one of these actions:

  • Double-click a cell.
  • Press Enter, F2, or Space on the active cell. This opens the editor seeded with the current value. Space is not a printable character, so it never type-to-replaces.
  • Type a printable character on the active cell. This is type-to-replace: the editor opens seeded with only what you typed, and replaces the old value.

This removes any ambiguity between a plain drag-to-select gesture and the start of an edit.

To commit a value, the built-in editors follow one keyboard contract. While an editor is open, the grid's keymap is ignored and the editor owns every key, so the keymap's commitUp (Shift+Enter) binding never fires during an edit, and Tab / Shift+Tab keep their native focus move. The actual per-editor behavior:

Keytext / numberselectdate
Enter (any, including Shift+Enter)Commits and moves down (Shift is ignored)Commits the picked choice, stays in placeCommits the typed ISO date and moves down
Tab / Shift+TabBlur-commits in place. Native focus moves onCloses the dropdown. Cancels the edit when nothing was pickedCloses the popover. Cancels the edit when nothing was picked
EscapeCancels and discardsCancels and discardsCancels and discards
Click away / focus lossCommits in placeCancels and discardsCancels and discards

The table means this:

  • Any Enter moves down, never up.
  • Tab never moves the active cell. It commits and lets the browser move focus.
  • A popover editor without an explicit pick cancels rather than commits.

An open edit stays bound to its row when the view changes. A re-sort, a filter, a column reorder, or a row insert, delete, duplicate, or reorder that moves the edited row moves the editor with it, and the typed draft survives. When the row or column leaves the view, the edit is cancelled and the typed value is discarded. It is never written into a row that is no longer visible.

Checkboxes have no edit mode

A click on the box toggles a checkbox cell directly. A click elsewhere in the cell selects it, and a second click toggles it. Enter and Space toggle the active cell. A drag that starts on a checkbox cell selects a range and toggles nothing. There is no intermediate editor state to commit or cancel.

Try each column's editor: Read-only refuses edits, Validated rejects an age under 18, Currency edits the raw number behind its locale format, Link opens with alt+Enter (its javascript: row stays plain text), Notes opens a multi-line editor, and renderCell shows a progress bar.

Text
Read-only
Validated
Checkbox
Select
Date
Currency
Link
Notes
renderCell
Silent Tiger
user0@example.com
63
Viewer
Jul 5, 2023
$12.50
javascript:alert(1)
First line Second line Third line
4
Quick Lion
user1@example.com
67
Viewer
Apr 3, 2021
$15.67
Short note
89
Swift Wolf
user2@example.com
40
Manager
Oct 27, 2020
$18.84
Short note
80
Eager Lion
user3@example.com
50
User
Oct 8, 2022
$22.01
Short note
51
Eager Tiger
user4@example.com
45
Viewer
Oct 7, 2024
$25.18
javascript:alert(1)
Short note
83
Silent Wolf
user5@example.com
24
User
Jun 1, 2020
$28.35
First line Second line Third line
15
Swift Lion
user6@example.com
34
Viewer
Oct 18, 2022
$31.52
Short note
62
Eager Lion
user7@example.com
21
Manager
Oct 7, 2021
$34.69
Short note
42

Built-in cell types

Every cell type implements the same small pipeline (display = toDisplayText ?? toText, clipboard/export = toText, sort = compare, fill = raw value) so one override changes how a type converts text to and from a value. The full per-feature contract, including how search and filter match values, is on custom cell types.

TypeValueNotes
textstringDefault when type is omitted. options.placeholder.
numbernumber | nulloptions.min/max/decimals. In the editor, ArrowUp/ArrowDown step by options.step (default 1) and clamp to min/max. options.format/locale render display-only locale formatting. Copy and export keep the raw number.
checkboxbooleanNo edit mode: direct toggle.
selectstring | nulloptions.choices: { value, label }[].
datestring | null (ISO yyyy-mm-dd)See below.
urlstring | nullThe value is a URL. Renders a safe link (http, https, mailto only); other values stay plain text. alt+Enter opens the active cell's link; so do Ctrl+click (Cmd+click on macOS) and middle-click on the link. options.label maps the URL to the shown text.
longTextstringMulti-line value. The cell shows the first 500 characters with line breaks as spaces; the editor is a popover textarea. options.maxLength (default 100,000), options.placeholder.
const columns = defineColumns<Person>()([
  { id: "name", header: "Name", accessorKey: "name", type: "text" },
  { id: "age", header: "Age", accessorKey: "age", type: "number", options: { min: 0, max: 120 } },
  { id: "active", header: "Active", accessorKey: "active", type: "checkbox" },
  {
    id: "role",
    header: "Role",
    accessorKey: "role",
    type: "select",
    options: { choices: [{ value: "admin", label: "Admin" }, { value: "user", label: "User" }] },
  },
  { id: "joined", header: "Joined", accessorKey: "joined", type: "date" },
] as const);

Date specifics

The value and clipboard representation are always ISO yyyy-mm-dd, regardless of display. Formatting is for presentation only, and never affects what the grid copies, pastes, or stores. options.displayFormat (an Intl.DateTimeFormatOptions object) and options.locale control toDisplayText. The editor is a popover date-picker built from shadcn's calendar and popover, with typed-input passthrough. The popover opens at the cell's start edge, and the cell text stays in place. Enter commits the value, and Escape cancels it.

{
  id: "joined",
  header: "Joined",
  accessorKey: "joined",
  type: "date",
  options: { displayFormat: { year: "numeric", month: "short", day: "numeric" }, locale: "en-US" },
}

options.calendarTrigger New shows the editor as a typed input with a calendar button. The calendar stays closed until the button opens it, and the button toggles it. The button reads its label from labels.grid.dateCalendarAriaLabel. Without the option, the calendar shows when the edit starts.

Press Enter on a date cell. The "Calendar at edit start" column opens the calendar at once. The "Calendar button" column shows a typed input with a calendar button, and the calendar opens when you press the button.

Name
Calendar at edit start
Calendar button
Silent Tiger
2023-07-05
2023-07-05
Quick Lion
2021-04-03
2021-04-03
Swift Wolf
2020-10-27
2020-10-27
Eager Lion
2022-10-08
2022-10-08
Eager Tiger
2024-10-07
2024-10-07
Silent Wolf
2020-06-01
2020-06-01

Number formatting

options.format (an Intl.NumberFormatOptions object) and options.locale (default "en-US") render the value with locale formatting: currency, percent, compact. Formatting is display only. toText, the editor, copy, paste, sort, and export keep the raw number, so a stored 0.25 under style: "percent" displays as 25% and edits as 0.25. When decimals is set and the format declares no fraction digits, decimals sets both the minimum and the maximum fraction digits. Invalid format options (for example style: "currency" without a currency) fall back to the raw value and log a one-time dev warning.

{
  id: "price",
  header: "Price",
  accessorKey: "price",
  type: "number",
  options: { format: { style: "currency", currency: "USD" } },
}

The value is a URL. The cell renders a safe link that opens in a new tab without a referrer and is not a tab stop. A plain click on the link selects the cell only. Ctrl+click (Cmd+click on macOS) and middle-click open the link in a new tab. alt+Enter (openLink) opens the link of the active cell. Only values with an http, https, or mailto protocol render as a link; every other value renders as plain text. options.label maps the stored URL to the shown link text. toText and copy stay the URL.

Only http, https, and mailto render as links

The allow-list is fixed, and a cell type cannot widen it. A javascript:, data:, or relative value never renders as a link and openLink does not open it.

Long text

longText stores multi-line text verbatim. The cell is one line and shows the first 500 characters with line breaks as spaces. The editor is a popover with a multi-line textarea. The popover opens over the cell, with its top-left corner on the cell's corner and its first line on the cell text: Enter commits and moves down, Shift+Enter and Alt+Enter insert a line break, Escape cancels, and an outside press commits the draft. options.maxLength (default 100,000) caps the stored text: the editor's textarea enforces it while typing, and a type-level validate rejects pasted, filled, or imported text longer than the default. A column with a custom maxLength declares its own validate to get the same rejection message on those paths.

Read-only

Per-column, static or row-dependent:

{ id: "email", header: "Email", accessorKey: "email", type: "text", readOnly: true }
// or
{ id: "email", header: "Email", accessorKey: "email", type: "text", readOnly: (row) => row.locked }

Grid-wide read-only wins

<DataGrid readOnly> (<DataGridProvider readOnly> in a composed grid) disables editing and delete everywhere, independent of any per-column readOnly. When readOnly turns on during an edit, the grid cancels the edit and discards the draft.

Validation

  • Age must be 18 or older (sync function)
  • Email must contain an @ (sync Standard Schema)
  • SKU must be unique — SKU-001 and SKU-002 are taken; the check takes ~800ms (async Standard Schema)
  • Price must be a number — decimals commit as the rounded integer (transforming schema)
  • Notes max 12 characters, SOFT (onInvalid: 'warn') — the value commits and the cell stays flagged; a short re-commit clears it
  • Zod Score integer 0–100 — zod: z.number().int().min(0).max(100)
  • Joi Code uppercase letters, digits, or dashes — joi: .pattern(/^[A-Z0-9-]+$/)
  • Valibot Tag at least 3 characters — valibot: v.pipe(v.string(), v.minLength(3))

Type a value that breaks a rule and press Enter: the editor stays open with the message under the cell, and the Age rule also fires a toast (from inside its own validate function). Or copy a column of ages (some under 18) from any spreadsheet and paste it onto Age: valid cells commit, invalid ones drop. Notes is the opposite: onInvalid: "warn" commits the value anyway and flags the cell (hover it for the message).

Name
Age
Email
SKU
Price
Notes
Zod Score
Joi Code
Valibot Tag
Ada Lovelace
28
ada@example.com
SKU-101
42
First algorithm
87
ADA-1843
math
Grace Hopper
34
grace@example.com
SKU-102
18
Compiled it
94
GRACE-1906
compilers
Alan Turing
41
alan@example.com
SKU-103
99
Broke Enigma
99
TURING-1912
computability
Katherine Johnson
25
katherine@example.com
SKU-104
7
Trajectory math
91
KATH-1918
orbital
Margaret Hamilton
22
margaret@example.com
SKU-105
56
Apollo nav
96
MARG-1936
software
Radia Perlman
30
radia@example.com
SKU-106
31
Spanning trees
88
RADIA-1951
networking
Tim Berners-Lee
19
tim@example.com
SKU-107
64
Invented the web
98
TIM-1955
web
Barbara Liskov
45
barbara@example.com
SKU-108
12
Subtyping
93
BARB-1939
types

See the live examples: Validation · Async validation · Server errors · Cross-field

validate accepts either of two forms:

  • A function that returns an error message to reject a value, or null to accept it. The function is your code, so side effects work. The demo's Age rule fires a toast from inside it.
  • Any Standard Schema for the value of the column: Zod, Joi, Valibot, Yup, ArkType, or any other library that implements the spec. gridcn detects the schema structurally ("~standard" in validate), so no adapter is necessary for a conforming library.

A cell type can declare the same validate as a default for its columns, and a column's own validate wins over the type's. Every validation path (a single-cell commit, a paste, a fill, updateCells, and import) resolves column.validate ?? cellType.validate. See custom cell types.

Invalid edits cannot commit

The editor stays open on a rejected value. This is Excel semantics, not a toast-and-revert pattern. The exception is onInvalid: "warn". See Soft rules below.

If a validator throws, or its promise rejects, the grid rejects the value and shows the error message. This applies to every validation path, and a throwing validator blocks the edit on an onInvalid: "warn" column too.

// Function form
{
  id: "age",
  header: "Age",
  accessorKey: "age",
  type: "number",
  validate: (value) => (typeof value === "number" && value < 18 ? "Must be 18 or older" : null),
}

// Standard Schema form — illustrative, schema-agnostic (works with Zod, Joi, Valibot, ...)
{
  id: "age",
  header: "Age",
  accessorKey: "age",
  type: "number",
  validate: z.number().min(18, "Must be 18 or older"),
}

Soft rules: onInvalid

By default a rejection is blocking: the value never commits. onInvalid: "warn" commits the value and flags the cell instead: the same ring, tint, and hover tooltip as a server error, auto-cleared by the next valid commit of that cell. The editor closes, because from the grid's point of view the commit succeeded:

{
  id: "notes",
  header: "Notes",
  accessorKey: "notes",
  validate: (value) => (typeof value === "string" && value.length > 12 ? "Max 12 characters" : null),
  onInvalid: "warn",
}

"warn" works with every validate form: function and sync/async Standard Schema. A rejection carries no transformed value, so a soft commit stores the raw input. Per-row softness is the function form's row argument (return null to skip the rule for that row). Bulk paths (paste, fill, updateCells) still drop rejected cells, even in soft columns.

Transforms are committed

A schema can transform the value, for example to trim, coerce, or round it. On success, the grid commits the schema's OUTPUT (result.value), not the raw input. So a transforming schema changes what data your grid stores, not only what value it allows:

validate: z.coerce.number().int() // "42.9" committed as 42, not "42.9"

Async schemas

The validate function of a Standard Schema can return a Promise, for an async uniqueness check for example. The same column definition then applies to a single edit and to every bulk path:

{
  id: "sku",
  header: "SKU",
  accessorKey: "sku",
  validate: z.string().refine(async (sku) => await isSkuFree(sku), "Already in use"),
}

On a single-cell edit, when you type a value and press Enter or Tab, or move away:

  • The editor stays open while the schema resolves. The built-in text and number editors mark their input readOnly, as the pending treatment. They never mark it disabled, so Escape still cancels. See Build your own cell type for the same pending and rejectionCount contract in a custom editor.
  • If the schema resolves with issues, the rejection message appears exactly like a sync rejection, and editing continues.
  • If the schema resolves successfully, the committed value is the schema's result.value. The grid then applies the pending cell-to-cell movement, the delta from Enter.
  • Escape, the start of a newer commit, or the unmount of the cell invalidates an in-flight validation. Its resolution is dropped silently, and never applied late.

On bulk paths

Paste, fill, CSV/XLSX import, and updateCells hold the batch while the schema resolves:

  • The grid validates a maximum of 32 cells at the same time. A paste of 10,000 cells therefore makes 32 concurrent calls, not 10,000.
  • Nothing changes until every verdict is in. The accepted cells then commit together, as one onDataChange with one operations batch, the same as a sync bulk write. An import above 5,000 rows validates in chunks internally, and still hands the whole row array to onImport at once.
  • A rejected cell drops, and the other cells of the batch still commit. One bad value never fails the batch. Paste, fill, and updateCells report the rejected cells through onValidationError when you pass one. updateCells also reports them in its return value, and import reports its rejected cells through its return value.
  • The committed value is the schema's result.value, so a transform applies on bulk paths too.
  • A newer operation supersedes a held batch: a second paste over the same area, a data change, a sort or filter change, or the unmount of the grid drops the older batch silently.
  • The import dialog keeps its Import button disabled while it validates. Cancel, or closing the dialog, aborts the build and drops the batch.

A slow schema makes a slow batch

A held batch is only as fast as your schema. A network call of 200 ms across 10,000 cells takes about 60 s at 32 concurrent calls. The grid gives no per-cell progress. For a large paste against a remote check, validate the block yourself with the processPaste override of Clipboard, which lets you make one request for the whole block.

Trusted feeds can skip validation

actions.updateCells(patches, { skipValidation: true }) bypasses validate completely. Use it when the producer already validated the values, to keep a high-rate feed synchronous.

What about a server-side rejection, after commit?

validate only rejects BEFORE commit. For a rejection your backend sends AFTER commit, for example a 422 response, see Server errors below.

Cross-field rules: validateRow

<DataGrid
  {...grid}
  validateRow={(row) => (row.discount > row.price ? { discount: "Discount above price" } : null)}
/>

validateRow runs once per touched row after a write gesture commits (a single edit, a paste, a fill, or a bulk update) against the row with ALL of the gesture's cells applied, so the verdict never depends on column order (a per-column validate runs mid-gesture and cannot guarantee that).

Return columnId -> message to mark cells, or null when the row is fine. Messages land in the same per-cell error display as Server errors (ring, tint, hover tooltip, aria-invalid), and the values still commit (the same stance as server errors, no rollback). A later write that makes the row consistent clears the verdict, even when it writes a different column than the marked one.

validateRow does not run on row insert or duplicate (a fresh default row is expected to be incomplete), on row deletion, or on a data array you replace yourself. It must be synchronous.

Server errors

validate runs before commit. A server can still reject an already-committed value later, for example with a 422 response that names the field. actions.setCellErrors paints that rejection on the cell, in the same round trip as onDataChange. Hovering the marked cell shows the message in a tooltip:

Set a Quantity above 100 and press Enter: the fake server rejects it after ~600ms and the cell is marked with a red ring — hover it to read the message.

Item
Quantity
Status
Widget
12
ok
Gadget
40
ok
Gizmo
8
ok
Doohickey
25
ok
Thingamajig
60
ok
Server errors: 0

The rejection lands a round trip after onDataChange, long after the grid is mounted, so the handler reaches the grid's actions through the imperative handle (no lifted store needed):

function OrdersGrid() {
  const [data, setData] = useState<readonly Order[]>(initialOrders);
  const gridRef = useRef<DataGridApi<Order> | null>(null);

  function handleDataChange(next: readonly Order[], change: DataChange<Order>) {
    setData(next);
    saveOrders(change.ops).catch((error) => {
      gridRef.current?.actions.setCellErrors(parseFieldErrors(error)); // [{ rowId, columnId, message }]
    });
  }

  return (
    <DataGrid ref={gridRef} data={data} columns={columns} getRowId={(row) => row.id} onDataChange={handleDataChange} />
  );
}

The cell gets the same ring, tint, and aria-invalid treatment as a rejected validate call. Open the cell for editing, and the message shows the same way a live rejection does.

The moment the user commits a new value to that cell, through any write path (a single edit, paste, fill, updateCells, or a cleared selection), the error clears on its own. The server can set it again if the new value is still wrong.

type CellErrorEntry = { rowId: string; columnId: string; message: string };

actions.setCellErrors(errors: readonly CellErrorEntry[]): void; // merges in, per cell
actions.clearCellErrors(targets?: readonly { rowId: string; columnId: string }[]): void; // omit targets to clear every cell

useDataGridCellErrors() reads the full map (ReadonlyMap<string, string>) for a summary view, for example an error count badge. Build a lookup key with the exported cellErrorKey:

import { cellErrorKey, useDataGridCellErrors } from "@/components/data-grid/data-grid";

const errors = useDataGridCellErrors();
const message = errors.get(cellErrorKey(row.id, "email"));

Not a data change

setCellErrors and clearCellErrors never touch data. They fire no onDataChange, and data-grid-history never records them. A server error is metadata about a cell, not an edit the user made. Undo must not need to walk past it.

Display override without a new type

renderCell swaps only the display, and keeps the type pipeline of the column unchanged: edit, sort, clipboard, and paste.

{
  id: "score",
  header: "Score",
  accessorKey: "score",
  type: "number",
  renderCell: ({ value }) => <ProgressBar value={value} />,
}

Custom cell types

A cell type is { Cell, Editor, toText, fromText, clearValue, isEmpty, compare?, align? }. Register custom types through the cellTypes prop on DataGridProvider. To get the same options and value inference through defineColumns that built-in types get, augment GridCellTypes through declaration merging and wrap the type in defineCellType.

The cellTypes prop merges over the built-ins: built-ins you do not name keep working, a key that names a built-in replaces that built-in, and a key set to null removes that type. Columns that use a removed or unknown type fall back to the built-in text type. See Build your own cell type.

See Build your own cell type for the full contract walk-through. It has a keypoint checklist distilled from lessons learned from the built-in types, on performance caching, SSR-safe formatting, editor commit semantics, and popover outside-click handling, plus a complete worked currency example. Recipes has a shorter minimal version, and API reference has the exact contract types.

On this page