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 savingerror lifecycle — the field dims and locks for ~800ms, then the commit is rejected with an inline error, and Enter retries:

Async cell editing

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.

.md

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:

tsx
<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:

FieldNotes
rowIdstable ID of the edited row
columnIdexact ID of the edited column
valuecommitted value — see the note below on how precisely it's typed
previousRowimmutable row captured when editing began
rowcomplete proposed row
changespartial 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:

tsx
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:

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.

Typed editors

column.type — the same field that picks the filter operator family — also selects the built-in editor:

typeEditor
"text" (default)single-line text input
"text" + wrap: trueauto-growing multi-line textarea
"number"right-aligned decimal input with steppers
"boolean"in-cell checkbox — toggles and commits directly, no editor popover
"enum" + optionsstrict 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.

tsx
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 its value), 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 / ArrowDown move the highlight (wrapping at the ends). Enter commits the highlighted option and moves down; Tab commits it and moves right; Escape cancels. 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 renderEditor if 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-29 is accepted, 2026-02-29 is 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 / PageUp move 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 at 0000-01-01 and 9999-12-31; out-of-range calendar slots are disabled placeholders rather than wrapped dates.
  • Commit keys. Enter commits the highlighted day and moves down; Tab commits it and moves right; clicking a day commits it in place; Escape cancels. Enter only substitutes the calendar's day when the typed text is itself a valid date — type garbage and press Enter and 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-activedescendant on 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:

KeyEffect
ArrowLeft / ArrowRighthighlighted day −1 / +1 day
ArrowUp / ArrowDownhighlighted 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:

  • renderEditor replaces 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 to type: "boolean": boolean cells toggle in place and never open an editor, so there is nothing for renderEditor to render.
  • parseEditValue replaces the built-in type parsing entirely — supply it on a number column and the "Not a number" guard and empty-commits-null behavior 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:

tsx
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 onChangeformatEditValue and parseEditValue bridge the two directions:

Custom cell editor

A renderEditor select bridges a numeric priority column to and from the string a native control hands back, via formatEditValue and parseEditValue.

.md

Its column definition:

tsx
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:

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 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)
PhaseMeaning
checkingan async editable is resolving; no editor yet
editingthe editor is open and accepting input
validatingvalidate is running on the draft
savingvalidation passed; onRowChange is in flight
erroronRowChange 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:

tsx
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. (Enter and Tab commit 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 validate rejects (returns a string) or onRowChange 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 onRowChange is in flight (the checking, validating, and saving phases), the field is read-only and marked aria-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.

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)
Enter / Spaceboolean cell focusedtoggle the checkbox and commit — no editor opens
Entereditingcommit, then move focus down
Cmd/Ctrl + Enterediting (multi-line)commit, then move focus down (plain Enter inserts a newline)
Tabeditingcommit, then move focus right
Arrow keysediting (date)move the calendar's highlighted day — ±1 day, ±7 for up/down
PageUp / PageDownediting (date)move the calendar back / forward a month
Escapeeditingcancel — 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.type picks the filter operator family.
  • 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.