Grid Editing
Editing
Controlled inline cell editing: typed editors driven by column.type, with an async editable / validate / commit lifecycle.
Make a column editable with editable: true, then save accepted rows through
onRowChange. The grid manages the draft; your application owns the stored data.
Editing is off by default.
This guide covers single-cell editing. Fill-handle drag, paste-to-edit, multi-cell edits, and undo are not supported.
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.
The controlled model
Set editable columns and publish the proposed row through your state. Return a
promise from onRowChange to wait for an asynchronous save:
<PretableSurface
ariaLabel="People"
columns={[{ id: "name", header: "Name", editable: true }]}
rows={rows}
getRowId={(row) => row.id}
onRowChange={({ rowId, row }) => {
setRows((previous) =>
previous.map((candidate) => (candidate.id === rowId ? row : candidate)),
);
}}
/>onRowChange receives these fields:
| 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 receives a PretableEditInput ({ rowId, columnId, row, column, value }) and may return a Promise<boolean>. While permission is resolving,
the built-in editor appears read-only and busy in the checking phase. A
false result closes the edit without saving. A rejected promise leaves an
established editor open with an error; retry checks permission again. See
Lifecycle.
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" },
];Single-line text
A text column opens a single-line input by default. Double-click a cell, or focus
it and press Enter or F2, to begin. Enter or Tab commits; Escape
discards the draft. The saved value below the grid changes only after a commit.
Edit a task title with the built-in single-line text field and inspect the saved string.
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 commits right and Shift+Tab left, Escape cancels, and blur commits in place.
Add line breaks in the built-in wrapping text editor, then inspect the saved string.
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.
Step an estimate by half an hour and see a number or null saved to the row.
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.
Toggle completion directly in the cell and inspect the saved boolean.
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.
- Identity is preserved. The field shows the current option label, but an unchanged selection or an explicit option choice commits its canonical value. Duplicate labels cannot redirect a choice to the first option.
- Typing filters the list. Labels and values match case-insensitively. Typed text must resolve to exactly one distinct option; ambiguous or unmatched text remains open with "Pick an option" on commit. Use arrows or a click to make an explicit choice.
- Clearing is supported. Empty or whitespace-only text commits
nullon Enter, Tab or blur unless an option was intentionally chosen by navigation. - Keyboard. ArrowUp/ArrowDown choose a suggestion; Enter commits down, Tab right, and Shift reverses either direction. Escape cancels. Clicking an option commits in place.
- Blur. An existing selection, unambiguous text, or an empty query commits in place. Invalid or ambiguous text does not silently become an option.
A custom parseEditValue remains authoritative. It receives the canonical value
as text for an explicit selection, or the typed query for text entry.
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.
Choose a status label and inspect its canonical value in the saved row.
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. After intentional calendar navigation, Enter chooses the cursor date and commits down (Shift+Enter up). Otherwise Enter commits typed text. Tab always commits the typed/current draft, moving right (Shift+Tab left); it never accepts a merely browsed date. Clicking a day chooses it in place. Escape cancels.
- 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.
- Calendar navigation keeps focus in the field. Day cells are announced through
aria-activedescendanton the input rather than by moving focus into the grid.
Choose a calendar date or type YYYY-MM-DD, then inspect the canonical string or null.
Browsing and text editing
Browsing the calendar never writes the field. ArrowUp/ArrowDown enter calendar navigation and move by a week; PageUp/PageDown and the month buttons browse months. In calendar navigation, Left/Right move by a day. Typing returns to text editing. Before calendar navigation, Left/Right/Home/End retain native caret behavior. Tab or blur therefore cannot save a date merely because you browsed another month. The cursor outline is separate from the selected-date fill.
The input is an editable combobox with aria-haspopup="grid". Its expanded,
controls and active-descendant attributes describe only mounted popup nodes;
DOM focus stays in the input. See the
WAI-ARIA combobox pattern.
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.
The built-in editor does not support 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 unmatched or ambiguous text), 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
Use components when you want
your own input or button with built-in editing behavior. Use renderEditor
when you need a different editor, such as a native select or date-range picker.
A custom renderer owns its field's accessible name, pending presentation, error message, and commit/cancel keys. It does not receive the built-in error UI automatically. The controller still prevents draft writes and duplicate commits during pending work.
This example uses a labelled PriorityEditor with pending and error feedback.
Priority is stored as a number; formatEditValue and parseEditValue bridge it
to the string used by a native select. Open the Code tab for the full editor.
A renderEditor select bridges a numeric priority column to and from the string a native control hands back, via formatEditValue and parseEditValue.
import type { PretableColumn } from "@pretable/react";
import { PriorityEditor } from "./PriorityEditor";
import type { Task } from "./data";
const columns: PretableColumn<Task>[] = [
{
id: "priority",
header: "Priority",
editable: true,
formatEditValue: (value) => String(value),
parseEditValue: (raw) => Number(raw),
renderEditor: (input) => <PriorityEditor {...input} />,
},
];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 while editing; ignored during checking, validating, or saving |
commit | (direction?: PretableFocusDirection) => void | commit the draft, optionally moving focus; ignored while permission or a commit is pending |
cancel | () => void | discard the edit and restore the cell |
status | PretableEditStatus | use checking, validating, and saving to present pending work |
error | string | undefined | render the message and associate it with your field |
seededFromTyping | boolean | undefined | true when typing started the edit; avoid selecting that initial character on focus |
parseEditValue(raw, input) turns the editor's string draft into the value handed to validate and onRowChange, replacing the built-in type parsing entirely. It may return the value or a promise of that value; the editor stays in validating until parsing completes. 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. In controlled rows mode, resolving onRowChange alone does not clear the editor: the supplied rows must also reflect the accepted change. Until then the cell remains in saving. The edit moves through phases observable as snapshot.editing.status:
| Phase | Meaning |
|---|---|
checking | edit permission is resolving; the editor is read-only |
editing | the editor is open and accepting input |
validating | the admitted draft is being read, parsed, or validated |
saving | validation passed; saving or awaiting controlled rows |
error | a callback failed; 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.
For most apps the default editor handles all of this and you never touch the phases directly. For custom editing UI with 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 date editor reverts an unparseable date on blur. The enum combobox keeps ambiguous or unmatched text open with a validation error. - Failures keep an established editor open. A returned validation message or a thrown/rejected permission, parser, validation, or save callback leaves the draft available with an inline error. Press
Enterto retry orEscapeto cancel. After a failed permission check, retry checks permission again before parsing or saving; a denied check closes the edit without a write. A failure before an editor can be established, such as an initial formatter throwing, leaves no active edit and emits a diagnostic. - Pending work locks the draft and commit path. During
checking,validating, andsaving, the field is read-only and markedaria-busy="true". Additional commit requests andsetDraftwrites are ignored, including requests from a custom editor. Parsing also runs under this protection, so a reentrant callback cannot dispatch a second save or change the draft being saved. - Cancellation retires the session. Late callback results cannot reopen a cancelled editor, alter its replacement, or move focus. Cancellation cannot undo a write already dispatched to your application; handle transaction rollback in the application if needed.
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; Shift reverses to up |
Cmd/Ctrl + Enter | editing (multi-line) | commit, then move focus down (plain Enter inserts a newline) |
Tab | editing | commit, then move focus right; Shift reverses to left |
| Arrow keys | editing (date) | up/down enter calendar navigation; arrows then move the cursor |
PageUp / PageDown | editing (date) | move the calendar back / forward a month |
Escape | editing | cancel — discard the draft, restore the cell |
Editing shortcuts apply to editable cells; otherwise the normal grid keyboard behavior applies. 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 calendar navigation keys in the date editor.
IME composition takes precedence over built-in editor and grid commands. Enter, Escape, Tab, and arrow events during composition retain browser behavior and do not save, cancel, step values, or start an edit. Custom editors must preserve composition behavior in their own key handlers.
See also
- Components — replace built-in fields and action buttons.
- 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.