Grid Editing
Editing
Controlled inline cell editing: typed editors driven by column.type, with an async editable / validate / commit lifecycle.
Inline editing has explicit ownership. In rows mode, a successful commit emits a typed onRowChange proposal containing the previous row and the complete proposed row; publish that row through your own state. In explicit-model mode, beforeRowChange validates the proposed batch before the surface publishes one row-model transaction. The grid owns the in-progress draft and lifecycle; your app or row model owns the data.
Editing is off by default — a cell only becomes editable when its column opts in.
Click a cell below, press Enter (or just start typing), edit, and commit — every column is a different typed editor: text, number, boolean, enum, and date. Edit Quantity to a fraction to see a synchronous validate rejection, or to a negative number to watch the full async saving → error lifecycle — the field dims and locks for ~800ms, then the commit is rejected with an inline error, and Enter retries:
One editor per column.type — text, number, boolean, enum, and date — committing through an 800ms onRowChange that rejects a negative quantity so you can watch the saving and error phases.
That grid is this page's worked example throughout: editable columns, a controlled rows array, and onRowChange publishing the complete proposed row. Open its Code tab to read or copy AsyncEditingGrid.tsx.
The controlled model
Rows-mode onRowChange receives a fully correlated proposal. Return a promise to make the commit await your save:
<PretableSurface
ariaLabel="People"
columns={columns}
rows={rows}
getRowId={(row) => row.id}
onRowChange={({ rowId, row }) => {
setRows((previous) =>
previous.map((candidate) => (candidate.id === rowId ? row : candidate)),
);
}}
/>The proposal preserves the row, ID, column, and value correlations inferred from the typed column tuple:
| Field | Notes |
|---|---|
rowId | stable ID of the edited row |
columnId | exact ID of the edited column |
value | committed value — see the note below on how precisely it's typed |
previousRow | immutable row captured when editing began |
row | complete proposed row |
changes | partial row patch produced by the column's direct or computed setter |
How precisely value is typed depends on how you declare your columns. A column that declares an accessor carries its exact value type through, so value is correlated to columnId — narrow on columnId and value narrows with it. A column without one — the plain PretableColumn<TRow> shape used throughout these docs — has no static value type to carry, so value is unknown and you check it yourself:
onRowChange={({ columnId, value }) => {
if (columnId === "quantity" && typeof value === "number" && value < 0) {
throw new Error("Quantity can't go negative");
}
}}Explicit-model mode instead accepts beforeRowChange={(changes) => ...}. It may reject asynchronously; if it resolves, all proposals publish atomically through the supplied model. Rows mode does not accept beforeRowChange, and model mode does not accept onRowChange.
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.
Typed editors
column.type — the same field that picks the filter operator family — also selects the built-in editor:
type | Editor |
|---|---|
"text" (default) | single-line text input |
"text" + wrap: true | auto-growing multi-line textarea |
"number" | right-aligned decimal input with steppers |
"boolean" | in-cell checkbox — toggles and commits directly, no editor popover |
"enum" + options | strict combobox with a typeahead-filtered option list |
"date" | strict YYYY-MM-DD field with a month-grid calendar popover |
An enum column without options falls back to the plain text editor — with no options to pick from there is nothing to be strict about.
const columns: PretableColumn<Task>[] = [
{ id: "title", header: "Title", editable: true }, // text
{ id: "notes", header: "Notes", editable: true, wrap: true }, // multi-line
{
id: "estimate",
header: "Estimate",
editable: true,
type: "number",
step: 0.5,
},
{ id: "done", header: "Done", editable: true, type: "boolean" },
{
id: "status",
header: "Status",
editable: true,
type: "enum",
options: [
{ value: "queued", label: "Queued" },
{ value: "running", label: "Running" },
{ value: "done", label: "Done" },
],
},
{ id: "due", header: "Due", editable: true, type: "date" },
];Multi-line text
A wrap: true text column edits in a textarea that auto-grows with the draft. Enter inserts a newline instead of committing; Cmd/Ctrl + Enter commits and moves down. Tab still commits right, Escape cancels, and blur commits in place.
Numbers
type: "number" opens a right-aligned decimal input. ArrowUp / ArrowDown step the draft by column.step (default 1), and a pair of clickable stepper buttons does the same — stepping edits the draft, it never commits. On commit, built-in parsing runs before your validate: a non-numeric draft is rejected with an inline "Not a number" and the editor stays open; an empty draft commits null.
Booleans
type: "boolean" cells never open an editor. The cell renders a checkbox that toggles and commits immediately — click it, or press Enter or Space on the focused cell — through the same async lifecycle as every other edit: an async editable still gates it, validate and onRowChange still await, and while the commit is in flight the control dims and disables. F2 and type-to-replace don't apply (there's no draft to seed), and non-editable boolean columns render the same checkbox disabled.
When a boolean commit fails — validate returned a string, or onRowChange threw — the cell shows the inline error just like the field editors do. Click the checkbox again to retry the toggle, or press Escape to cancel the failed edit.
Non-boolean cell values are coerced, by the same rule the boolean filter uses: "true" / 1 / "1" are checked, "false" / 0 / "0" are unchecked, and anything else falls back to plain truthiness. So a cell holding 1 renders checked, matches the True filter, and toggles to false. Storing real booleans is still the better idea — a commit always writes one, so the first edit converts the cell anyway.
A boolean column may declare options to relabel its two states — [{ value: "true", label: "Yes" }, { value: "false", label: "No" }] — but the values must stay "true" and "false". Matching happens on the coerced boolean, which is only ever one of those two strings, so an option carrying any other value would render in the filter checklist and then match no rows.
Enums
An enum column that declares options (the same { value, label? }[] the filter checklist uses) edits in a combobox: a text input plus a listbox of the column's options.
- Seeded with the label. The field opens showing the current option's
label(falling back to itsvalue), so it reads the way the cell does. - Typing filters the list. The draft is matched as a case-insensitive substring against both the label and the value; the full list shows until the user types.
- Keyboard.
ArrowUp/ArrowDownmove the highlight (wrapping at the ends).Entercommits the highlighted option and moves down;Tabcommits it and moves right;Escapecancels. Clicking an option commits it in place. - Strict by default. Committing text that matches no option is rejected with an inline "Pick an option" and the editor stays open. The combobox always resolves to one of the declared options — clearing a cell isn't reachable from it, because an emptied field just shows the full list again. Use
renderEditorif you need a clearable control. - Blur reverts. Clicking away with unmatched text cancels the edit rather than leaving a rejected value stuck open on the cell. (Text that does match an option commits in place, like the other editors.)
The strictness is the point: the combobox picks from the declared set, it doesn't create new members. For a creatable or multi-select control, use renderEditor.
An enum column with no options gets the plain text editor and no strictness — the draft commits as typed.
Dates
type: "date" opens a strict YYYY-MM-DD text field with a month-grid calendar in a popover anchored to the cell. The calendar starts weeks on Monday, marks today and the currently highlighted day, and dims days from the neighbouring months. A commit always produces the canonical string YYYY-MM-DD (or null) — the same shape the date filter operators compare against.
- Strict ISO only after trimming. Surrounding whitespace in user-entered text is removed before validation. Locale formats (
08/06/2026), unpadded parts (2026-8-6), and calendar overflow (2026-02-30,2026-13-01) are rejected on commit with an inline "Use YYYY-MM-DD", and the editor stays open. Leap days are checked properly:2024-02-29is accepted,2026-02-29is not. - An empty or whitespace-only field commits
null. Clearing the field is how you clear a date cell. - Typing retargets the calendar. As soon as the field holds a complete valid date, the popover jumps to that month and highlights that day.
- Month navigation.
PageDown/PageUpmove forward and back a month, as do the ‹ and › buttons in the popover header. The day-of-month is clamped to the target month's length, so 31 January + 1 month is 28 (or 29) February. Navigation also clamps at0000-01-01and9999-12-31; out-of-range calendar slots are disabled placeholders rather than wrapped dates. - Commit keys.
Entercommits the highlighted day and moves down;Tabcommits it and moves right; clicking a day commits it in place;Escapecancels.Enteronly substitutes the calendar's day when the typed text is itself a valid date — type garbage and pressEnterand it still reaches the parser and is still rejected, rather than silently committing whatever month the calendar drifted to. - Blur commits or reverts. Clicking away with a valid (or empty) field commits; clicking away with an unparseable date cancels the edit rather than leaving a rejected value stuck open on the cell.
- Focus never leaves the field. The popover is a passive surface: it takes no focus, so the day cells are announced through
aria-activedescendanton the input rather than by moving focus into the grid.
Arrow keys move the calendar, not the caret
This is the one behavior worth knowing before you use it. Inside a date editor:
| Key | Effect |
|---|---|
ArrowLeft / ArrowRight | highlighted day −1 / +1 day |
ArrowUp / ArrowDown | highlighted day −7 / +7 days (a week) |
PageUp / PageDown | −1 / +1 month |
The field is a fixed ten-character date that opens fully selected, so caret movement is worth little and picking a nearby day is the common action. The trade-off is real, though: because navigating writes the highlighted day back into the field, pressing an arrow key while a partial date is typed replaces what you typed. Type 2026-08, press ArrowRight, and the field becomes a full date. To correct a mistake, use Backspace and retype rather than arrowing back to it.
What the editor accepts from your cell values
The built-in date value is only a canonical YYYY-MM-DD string or null.
There is no normalization step for Date, epoch, date-time, padded, localized,
empty-string, whitespace, or undefined values. If runtime data contains one,
the editor preserves its raw text; an untouched blur cancels without parsing or
committing it. After the user changes the draft, the parser trims the typed
text and accepts only a canonical date or an empty result (which commits
null). This input convenience does not loosen stored row values: a padded
value already present in application data is still invalid and is never
normalized automatically.
Out of scope for the built-in editor, by design: time-of-day, date ranges, min/max bounds, and a configurable week start. Reach for renderEditor — paired with parseEditValue and formatEditValue — when you need any of them.
Overriding the built-ins
Two column hooks take precedence over the typed defaults:
renderEditorreplaces the built-in editor for any column that opens one — text, multi-line, number, the enum combobox, and the date calendar. It does not apply totype: "boolean": boolean cells toggle in place and never open an editor, so there is nothing forrenderEditorto render.parseEditValuereplaces the built-in type parsing entirely — supply it on a number column and the "Not a number" guard and empty-commits-nullbehavior are yours to reimplement.
Validating
validate runs on commit, before onRowChange. 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,
type: "number",
validate: (value) => {
if (typeof value === "number" && 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 onRowChange once validation passes.
On a typed column, built-in parsing runs first: by the time validate sees the value, a number column has already rejected non-numeric drafts ("Not a number") and turned an empty draft into null, an enum column with options has resolved the typed label to that option's value (rejecting anything that matches none), and a date column has rejected anything that isn't a real YYYY-MM-DD day ("Use YYYY-MM-DD") — so validate is for domain rules, not parsing.
Custom editors
To render your own editor instead of the built-in one — a <select>, a date-range picker, a tag input — supply renderEditor. Pair it with parseEditValue (string → your value type) and formatEditValue (your value → the string the editor seeds from). Priority below stores a plain number, but its <select> only ever hands back a string on onChange — formatEditValue and parseEditValue bridge the two directions:
A renderEditor select bridges a numeric priority column to and from the string a native control hands back, via formatEditValue and parseEditValue.
Its column definition:
const columns: PretableColumn<Task>[] = [
{
id: "priority",
header: "Priority",
editable: true,
formatEditValue: (value) => String(value),
parseEditValue: (raw) => Number(raw),
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="1">Low</option>
<option value="2">Medium</option>
<option value="3">High</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 onRowChange, replacing the built-in type parsing entirely. formatEditValue(value, input) produces the initial string shown when the editor opens. Supply both when your stored value isn't a plain string (an application-owned instant Date, an enum) so the custom edit round-trip stays type-correct. For an instant, the application must choose its calendar-zone projection; these hooks do not make the value eligible for built-in date processing.
renderEditor wins over the built-in editor for every column that opens one. The exception is type: "boolean" — boolean cells toggle in place and never open an editor, so renderEditor is ignored there.
Lifecycle
A commit is pessimistic: the grid keeps showing the draft while the work runs and only clears the edit once onRowChange 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 (onRowChange 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; onRowChange is in flight |
error | onRowChange 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 onRowChange throws or rejects, the edit enters error (it does not clear) so you can surface the failure and let the user retry or cancel.
saving and error are the two phases text on this page can only assert, not show — the field visibly goes read-only and aria-busy while a commit is in flight, and a rejection leaves the editor open with an inline message. That's exactly what happens in the grid at the top of this page when you edit Quantity to a negative number: the field dims for ~800ms (saving), then the commit is rejected with an inline message (error).
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.getState().editing — { rowId, columnId, draft, status, error? } — to drive your own in-cell editor or status affordance:
const { editing } = grid.getState();
if (editing?.status === "saving") {
// show a spinner in the cell at editing.rowId / editing.columnId
}Default editor behavior
The built-in editors handle 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.) The exceptions are the enum combobox and the date editor, where a draft the editor can't resolve — text matching no option, or an unparseable date — reverts on blur instead. - Failures keep the editor open. When
validaterejects (returns a string) oronRowChangethrows, 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, oronRowChangeis in flight (thechecking,validating, andsavingphases), the field is read-only and markedaria-busy="true", so the user can't edit a value mid-save.
Boolean cells have no field: pending renders as a dimmed, disabled checkbox, and a failed commit shows the same inline error on the cell — click to retry, Escape to cancel.
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 / Space | boolean cell focused | toggle the checkbox and commit — no editor opens |
Enter | editing | commit, then move focus down |
Cmd/Ctrl + Enter | editing (multi-line) | commit, then move focus down (plain Enter inserts a newline) |
Tab | editing | commit, then move focus right |
| Arrow keys | editing (date) | move the calendar's highlighted day — ±1 day, ±7 for up/down |
PageUp / PageDown | editing (date) | move the calendar back / forward a month |
Escape | editing | cancel — discard the draft, restore the cell |
A begin trigger on a non-editable column is a no-op. Editable boolean columns never open an editor: Enter and Space toggle in place, F2 and type-to-replace do nothing, and Escape cancels a failed toggle. 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 — as are ArrowUp / ArrowDown in the number and enum editors, and every arrow key in the date editor.
See also
- Filtering — the same
column.typepicks the filter operator family. - 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.