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:
<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 }:
| Field | Type | Notes |
|---|---|---|
rowId | string | the getRowId of the edited row |
columnId | string | column.id of the edited cell |
value | unknown | the committed value (after parseEditValue, if supplied) |
row | TRow | the 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:
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:
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):
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:
| Field | Type | Notes |
|---|---|---|
draft | unknown | the current in-progress value |
setDraft | (value: unknown) => void | update the draft as the user types |
commit | (direction?: PretableFocusDirection) => void | commit the draft, optionally moving focus ("down", "right", …) |
cancel | () => void | discard 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:
checking → editing → validating → saving → (cleared)
↑__________| |
invalid (validate |
returned a string) ↓
error (onCellEdit threw)| Phase | Meaning |
|---|---|
checking | an async editable is resolving; no editor yet |
editing | the editor is open and accepting input |
validating | validate is running on the draft |
saving | validation passed; onCellEdit is in flight |
error | onCellEdit 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:
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. (
EnterandTabcommit and move; blur commits and stays put.) - Failures keep the editor open. When
validaterejects (returns a string) oronCellEditthrows, the editor stays open and renders the message inline below the field — so the user can fix the value and try again. PressEnterto retry a failed commit, orEscapeto cancel. - Async work locks the field. While an async
editable,validate, oronCellEditis in flight (thechecking,validating, andsavingphases), the input is read-only and markedaria-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.
| Key | When | Effect |
|---|---|---|
Enter / F2 | cell focused | begin editing the focused cell |
| Double-click | on an editable cell | begin editing that cell |
| Any printable char | cell focused | begin editing, seeding the draft with that character (type-to-replace) |
Enter | editing | commit, then move focus down |
Tab | editing | commit, then move focus right |
Escape | editing | cancel — 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:
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.editingto build your own editor. - API reference —
PretableEditInput,PretableEditorInput,PretableEditState,PretableEditStatustypes.