Grid Editing

Editing

Controlled inline cell editing with an async editable / validate / commit lifecycle.

Inline editing is controlled: the grid never mutates your rows. A successful commit fires onCellEdit({ rowId, columnId, value, row }), and you write the new value into your own state and feed the updated rows back down. The grid owns the in-progress edit (the draft, the lifecycle phase); your app owns the data.

Editing is off by default — a cell only becomes editable when its column opts in.

The controlled model

onCellEdit is a prop on both <Pretable> and <PretableSurface>. It receives the committed value and the row it came from; return a promise to make the commit await your save:

tsx
<PretableSurface ariaLabel="People" columns={columns} rows={rows} getRowId={(row) => row.id} onCellEdit={({ rowId, columnId, value, row }) => { // apply `value` to your own state — the grid does not touch `rows` setRows((prev) => prev.map((r) => (r.id === rowId ? { ...r, [columnId]: value } : r)), ); }} />

The payload is { rowId, columnId, value, row }:

FieldTypeNotes
rowIdstringthe getRowId of the edited row
columnIdstringcolumn.id of the edited cell
valueunknownthe committed value (after parseEditValue, if supplied)
rowTRowthe row object the edit targets

Making a column editable

Set editable on the column. It's false by default; pass true to allow editing unconditionally, or a function to gate it per cell:

tsx
import type { PretableColumn } from "@pretable/react"; const columns: PretableColumn<Person>[] = [ { id: "name", header: "Name", editable: true }, { id: "email", header: "Email", // gate per cell — sync or async (return a Promise<boolean>) editable: ({ row }) => row.status !== "locked", }, ];

The function form receives a PretableEditInput ({ rowId, columnId, row, column, value }) and may return a Promise<boolean>, so a permission check can hit the network before the editor opens. While an async editable is pending, the edit sits in the checking phase (see Lifecycle); if it resolves false, the edit is cancelled and no editor appears.

Validating

validate runs on commit, before onCellEdit. Return true to accept, or a string to reject — the string becomes the validation message and the cell stays in edit mode so the user can fix it. It can be sync or async:

tsx
const columns: PretableColumn<Person>[] = [ { id: "age", header: "Age", editable: true, parseEditValue: (raw) => Number(raw), validate: (value) => { if (typeof value !== "number" || Number.isNaN(value)) return "Enter a number"; if (value < 0) return "Age cannot be negative"; return true; }, }, ];

validate(value, input) receives the parsed value and the same PretableEditInput. A returned string keeps the edit open with snapshot.editing.error set to that message; a Promise<true | string> lets you validate against a server. Commit only proceeds to onCellEdit once validation passes.

Custom editors

The default editor is a text input. To render your own — a <select>, a date picker, a numeric stepper — supply renderEditor. Pair it with parseEditValue (string → your value type) and formatEditValue (your value → the string the editor seeds from):

tsx
const columns: PretableColumn<Person>[] = [ { id: "status", header: "Status", editable: true, renderEditor: ({ draft, setDraft, commit, cancel }) => ( <select autoFocus value={String(draft ?? "")} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") cancel(); }} onBlur={() => commit()} > <option value="active">Active</option> <option value="paused">Paused</option> </select> ), }, ];

renderEditor receives a PretableEditorInput — the edit input (rowId, columnId, row, column, value) plus the live draft controls:

FieldTypeNotes
draftunknownthe current in-progress value
setDraft(value: unknown) => voidupdate the draft as the user types
commit(direction?: PretableFocusDirection) => voidcommit the draft, optionally moving focus ("down", "right", …)
cancel() => voiddiscard the edit and restore the cell

parseEditValue(raw, input) turns the editor's string draft into the value handed to validate and onCellEdit. formatEditValue(value, input) produces the initial string shown when the editor opens. Supply both when your stored value isn't a plain string (a number, a Date, an enum) so the round-trip stays type-correct.

Lifecycle

A commit is pessimistic: the grid keeps showing the draft while the work runs and only clears the edit once onCellEdit resolves. The edit moves through a sequence of phases, observable as snapshot.editing.status:

text
checking → editing → validating → saving → (cleared) ↑__________| | invalid (validate | returned a string) ↓ error (onCellEdit threw)
PhaseMeaning
checkingan async editable is resolving; no editor yet
editingthe editor is open and accepting input
validatingvalidate is running on the draft
savingvalidation passed; onCellEdit is in flight
erroronCellEdit rejected; snapshot.editing.error holds the message

When validate returns a string the edit returns to editing with snapshot.editing.error set to that message. When onCellEdit throws or rejects, the edit enters error (it does not clear) so you can surface the failure and let the user retry or cancel.

For most apps the default editor handles all of this and you never touch the phases directly. If you render cells yourself (a custom render, or the headless engine), read grid.getSnapshot().editing{ rowId, columnId, draft, status, error? } — to drive your own in-cell editor or status affordance:

tsx
const { editing } = grid.getSnapshot(); if (editing?.status === "saving") { // show a spinner in the cell at editing.rowId / editing.columnId }

Default editor behavior

The default text editor handles commit, errors, and pending state for you — no wiring required:

  • Blur commits in place. Clicking away from an open editor commits the current draft without moving focus. (Enter and Tab commit and move; blur commits and stays put.)
  • Failures keep the editor open. When validate rejects (returns a string) or onCellEdit throws, the editor stays open and renders the message inline below the field — so the user can fix the value and try again. Press Enter to retry a failed commit, or Escape to cancel.
  • Async work locks the field. While an async editable, validate, or onCellEdit is in flight (the checking, validating, and saving phases), the input is read-only and marked aria-busy="true", so the user can't edit a value mid-save.

For custom styling, two DOM hooks are exposed: the editing cell carries data-pretable-edit-status (the current lifecycle phase), and the inline error element carries data-pretable-edit-error. The @pretable/ui skin styles both — the field outline turns --pretable-text-error while invalid, and the message renders in the same color.

Keyboard

Editing reuses the focused cell from the selection model.

KeyWhenEffect
Enter / F2cell focusedbegin editing the focused cell
Double-clickon an editable cellbegin editing that cell
Any printable charcell focusedbegin editing, seeding the draft with that character (type-to-replace)
Entereditingcommit, then move focus down
Tabeditingcommit, then move focus right
Escapeeditingcancel — discard the draft, restore the cell

A begin trigger on a non-editable column is a no-op. While an editor is open it owns keystrokes; Enter, Tab, and Escape are handled by the editor and don't fall through to grid navigation.

Worked example

A small grid where name is editable and onCellEdit updates React state:

tsx
import { useState } from "react"; import { PretableSurface, type PretableColumn } from "@pretable/react"; interface Person extends Record<string, unknown> { id: string; name: string; age: number; } const columns: PretableColumn<Person>[] = [ { id: "name", header: "Name", widthPx: 200, editable: true }, { id: "age", header: "Age", widthPx: 100, editable: true, parseEditValue: (raw) => Number(raw), validate: (value) => typeof value === "number" && !Number.isNaN(value) && value >= 0 ? true : "Enter a non-negative number", }, ]; export function EditableGrid() { const [rows, setRows] = useState<Person[]>([ { id: "r1", name: "Ada", age: 36 }, { id: "r2", name: "Linus", age: 54 }, ]); return ( <PretableSurface<Person> ariaLabel="People" columns={columns} rows={rows} getRowId={(row) => row.id} viewportHeight={300} onCellEdit={({ rowId, columnId, value }) => { setRows((prev) => prev.map((r) => (r.id === rowId ? { ...r, [columnId]: value } : r)), ); }} /> ); }

Click a cell, press Enter (or just start typing), edit, and press Enter again — onCellEdit fires, your state updates, and the new rows flow back into the grid.

See also

  • Selection — focus is the cell editing begins on.
  • Keyboard — the full keyboard contract.
  • Headless engine — read snapshot.editing to build your own editor.
  • API referencePretableEditInput, PretableEditorInput, PretableEditState, PretableEditStatus types.