Home/ Cookbook/ Forms/ Inline edit on row

Build inline-editable cells that feel like a spreadsheet

By the end of this recipe you'll have a table where tapping a cell opens it for edit, Enter saves, Esc reverts, Tab moves to the next cell, the row paints in optimistically while the server confirms, and a failed save rolls the row back with the error attached to the specific cell.

Forms 3 primitives · 1 block · 1 pattern ~30 min React · @corelithzw/react

Overview

A per-cell state machine — 'view' | 'editing' | 'saving' | 'error' — plus optimistic UI so the row updates the instant the user hits Enter, and rolls back if the server says no.

The gold-ledger and stock-take tables in operator apps live in this pattern. Someone is counting bags of mealie-meal at a depot and needs to type 14 into a quantity cell, hit Tab, type 6 into the next, and move on. Forcing a modal per row or a "Save all" button at the bottom turns a 20-cell update into a wrist-cramp ritual.

The opinionated bits: state is per cell, not per row (two cells in the same row can be in different states); the row paints with the new value before the server confirms; on 4xx the cell gets a red ring and a one-line error, not a banner; on 5xx the row rolls back and a Toast offers a retry.

Skip this recipe if the table is read-only or the edits trigger heavy downstream effects (price changes that recompute invoices). Use the autosave drawer recipe — the explicit context helps the user understand the blast radius.

What you'll build

Three cell states the user actually sees.

Huchu · Gold ledger

Stock count · Park Centre

SKUQtyLoc
MM-2514│A-3
SG-106B-1
OL-2L22A-1

Enter saves · Esc reverts · Tab next cell

01 Editing — keyboard wins
Huchu · Gold ledger

Stock count · Park Centre

SKUQtyLoc
MM-2514A-3
SG-106…B-1
OL-2L22A-1

Saving · optimistic paint

02 Saving — optimistic, no blur
Huchu · Gold ledger

Stock count · Park Centre

SKUQtyLoc
MM-25−1A-3
SG-106B-1
OL-2L22A-1

Qty can't be negative · Enter to retry, Esc to revert

03 Error — cell-scoped

You can see this live in

Portal demos that wire this inline-edit table in context. Open one and tap a cell.

Required pieces

Everything this recipe pulls from @corelithzw/react.

@corelithzw/react exports used here: DataTable, Input, InputGroup, Button, Toast, Stack. We introduce a small useEditableCell hook here; reuse it on every editable table.

Step-by-step build

01

Per-cell state — keyed by rowId + colKey

A single editingCell string at the table level means only one cell can be in any non-view state at a time. That's almost always what you want: the user is typing in one place, and a server save for a different cell can race independently. Keep status maps for saving and errors keyed by the same composite key.

Code-only step

saving, errors: maps — multiple cells can be in flight.

Switch to the Code tab to see the snippet.

Step 1 · Per-cell state@corelithzw/react
02

Optimistic rows — local edits live alongside server data

Hold a localPatch map keyed by row id; the row renders { ...server, ...localPatch[rowId] }. On save success we clear that key; on error we keep it and let the cell render its error state so the user can fix or revert.

Code-only step

Save OK → clear(rowId) (the server now matches).

Save 5xx → rollback(rowId) + Toast retry.

Switch to the Code tab to see the snippet.

Step 2 · Optimistic UI
03

Keyboard contract: Enter saves, Esc reverts, Tab moves to next editable cell

Behaviour your users instinctively expect from Excel. Enter blurs and triggers save; Esc blurs without saving; Tab is a save-and-advance. Build the move-to-next as a list of editable column keys per row, so a Tab off the last cell of row N lands on the first editable cell of row N+1.

Code-only step

Validation runs synchronously on Enter; failing keeps the cell editing so the user can fix the value.

Switch to the Code tab to see the snippet.

Step 3 · Keyboard
3.5

Reusable inline-edit wrapper — compose with the .p-inline-edit primitive

Every editable cell is the same trio: a text input, a save check, a cancel cross. The new .p-inline-edit primitive in components.css ships exactly that shell — focus ring, hover states, padding, and the two icon slots — so your EditableCell stops carrying its own border / focus / icon-button rules. Compose it like this:

Step 3.5 · .p-inline-edit shellcomponents.css
04

Validate per cell, both client- and server-side

A client-side validator catches the obvious (negative qty, non-numeric, max length); the server is the final word. Both surface as the same per-cell error: red ring + one-line message under the cell. Don't ever silently coerce — "−1" rounding to 0 will lose you a stocktake somewhere.

Code-only step

5xx (network) → rollback row, Toast "Retry".

4xx (validation) → keep optimistic value, mark cell error.

Switch to the Code tab to see the snippet.

Step 4 · Validation + API shape
05

Wire it together — one row renderer, three rendering branches per cell

The row renderer reads statusOf(rowId, col) for each editable cell and returns one of: read-only value, editing EditableCell, or read-only with error chip below. Saving is just a paint colour — the value already shows the optimistic update.

Code-only step

Saving is a visual tint on the view branch, not its own render path.

Switch to the Code tab to see the snippet.

Step 5 · Cell renderer

Final composition

The StockTable, assembled. Cell state machine, optimistic patches, keyboard contract, error recovery.

Stock count · Park Centre

SKUQtyLoc
MM-25 14│ A-3
SG-10 6 … B-1
OL-2L −1 A-1

Enter saves · Esc reverts · Tab next cell

StockTable.tsx@corelithzw/react

Variations

Three forks. Each a small diff off the final composition.

Dropdown cell (Select instead of Input)

For status / category cells. Swap CellInput for a CellSelect with the same commit/cancel contract.

{edit.isEditing(row.id, col) ? (
  <Select
    value={value}
    autoFocus
    options={STATUS_OPTIONS}
    onChange={(v) => commit(row, col, v)}
    onBlur={() => edit.cancelEdit()}
  />
) : (...)}

Bulk commit (one Save button)

Hold optimistic patches until the user taps "Save all". Drop the per-cell API call; commit becomes a local state set.

// commit just stages:
const commit = (row, col, v) => {
  optimistic.patch(row.id, { [col]: v });
  edit.cancelEdit();
};
// new button:
<Button onClick={async () => {
  await saveAll(optimistic.rows);
}}>Save all</Button>

Paste a column

Spreadsheet paste of a single column. Intercept paste on the cell input; if the clipboard has newlines, distribute values down the column.

onPaste={(e) => {
  const lines = e.clipboardData.getData('text')
    .split(/\r?\n/).filter(Boolean);
  if (lines.length > 1) {
    e.preventDefault();
    lines.forEach((v, i) =>
      commit(rows[rowIdx + i], col, v));
  }
}}

Accessibility

What this recipe takes care of for you.

  • Read-only cells are buttons. Each closed cell is role="button" + tabIndex={0}. Keyboard users tab into it, Enter opens edit.
  • Editing input auto-focuses and selects. Opening a cell focuses the input and selects its contents — typing replaces, no "click then triple-click" ritual.
  • Errors are linked, not banner-only. Each cell carries aria-invalid and aria-describedby pointing to its inline error. Screen readers read the column, the value, and the reason it's bad.
  • Saving doesn't blur the value. The optimistic value renders at 60% opacity, not as a spinner. The cell never disappears — the user's place is preserved.
  • Toast retry is keyboard-reachable. Network failures pop a Toast with a "Retry" button; Toast is rendered inside an aria-live="polite" region and the button is in the natural tab order.
  • Tab order matches column order. Tab inside an editing cell commits and advances to the next editable cell — never trapping focus inside one input.
  • Esc never destroys explicit input. Esc cancels the in-flight edit but does not roll back already-saved cells. Confirmed work stays confirmed.