Home/ Cookbook/ Tables/ Editable cells

Build editable cells — Tab right, Enter saves, Esc reverts

By the end of this recipe you'll have an Excel-style editable table: tap a cell to edit, Enter to save, Esc to revert, Tab to move right, with per-cell validation, optimistic updates, fill-down, and undo.

Tables 4 primitives · 1 block · 1 pattern ~35 min React · @corelithzw/react

Overview

A table that reads like a table and edits like a spreadsheet. One cell is in edit mode at a time. Validation runs per-cell. Saves are optimistic — the value updates immediately, with a Toast undo if the server says no.

Inventory people, accountants, dispatchers — anyone who does bulk data entry has Excel muscle memory. The whole job of an editable table is to honour that memory: Tab right, Enter down, Esc abort. Anything else and you've made them switch to a CSV export.

Never use a modal for a cell edit. If a single price needs a dialog, you've made the table a list of read-only rows with edit buttons — which is fine, but it isn't an editable table. Inline editing is the whole feature.

Skip this recipe if your row has more than ~5 editable fields (use a drawer form instead — see the autosave-drawer recipe), or if edits trigger workflows (PO approval, audit trail) — those need a more explicit gesture.

What you'll build

Default, mid-edit, and a validation error state.

Huchu

Stock prices

SKUQtyPrice
Rebar 12mm240$890
Cement OPC120$1,200
Bricks clay18,000$0.18
Sand river60$22
01 Default — read mode
Huchu

Stock prices

SKUQtyPrice
Rebar 12mm240$925
Cement OPC120$1,200
Bricks clay18,000$0.18
Enter save · Esc revert · Tab next
02 Editing — cell focused, hints below
Huchu

Stock prices

SKUQtyPrice
Rebar 12mm240-5
Price must be ≥ 0.
Cement OPC120$1,200
Bricks clay18,000$0.18
03 Validation — error shows, doesn't save

Required pieces

Input for the edit, Toast for undo, a per-cell validator, a keyboard nav reducer.

@corelithzw/react exports used here: Input, Toast, Button, Kbd.

Step-by-step build

01

Edit state — one cell at a time, identified by [row, col]

A single global "active cell" state plus a "draft value" — that's the whole state machine. Per-cell useState would scatter edit logic everywhere. Tab/Arrow keys all just mutate the active-cell coordinate.

Code-only step

One state, one cell. The original is what we revert to on Esc — never recompute it from the row data, or "save then revert" gets weird.

Step 1 · EditState@corelithzw/react
02

The cell — read/edit views in one component

One component, two render branches. The read branch is a button so Tab lands on it; the edit branch is an Input with autofocus. Both render in the same <td> so grid layout doesn't shift on transition.

Rebar 12mm
240
Step 2 · Cell
03

Validation — per-cell, per-column

Each column owns a validator: (raw, row) => string | null — returns the error message or null. Validate on every keystroke so the error appears as the user types. Block the save (don't dismiss the editor) until validation passes — Excel teaches users that the cell stays "hot" until it's valid.

Code-only step

Validators take the whole row, not just the value — for cross-field rules ("Qty × Price ≤ stock cap"). One file lists every validator.

Step 3 · Validation
04

Optimistic save + undo Toast

Update the row immediately, fire the mutation, show a 5-second Toast with "Undo". On error, revert and surface the failure. The user sees the change happen instantly — that's what makes the table feel like Excel.

Saved price
Step 4 · Optimistic + Toast
05

Power-keys — Cmd+D fill-down, Cmd+Z undo

Two power features that put this in Excel territory. Fill-down copies the current cell to all selected rows below. Undo pops the last cell-change off a small ring buffer. Both worth maybe 40 lines combined — they earn their keep ten times over.

D fill down Z undo
Step 5 · Power keys

Final composition

~200 lines: state machine, cell renderer, validation, optimistic save, fill-down, undo.

SKUQtyPrice
Rebar 12mm240
Cement OPC120$1,200
Enter save · Esc revert · Tab next · ⌘D fill down · ⌘Z undo
StockTable.tsx@corelithzw/react

Variations

Three forks. Same edit machine.

Select cells — dropdown instead of input

For columns whose values are an enum (status, category), swap the Input for a Select. The state machine doesn't change — the editor render does.

{isEditing && col === 'status'
  ? <Select autoFocus
      value={draft}
      onChange={onChange}
      onBlur={onCommit}
      options={STATUS_OPTS}
    />
  : /* Input as before */}

Batched save — debounce, not per-cell

For high-volume edits (price-list update), collect pending changes for 1 second and PATCH once. Trade some latency for fewer server requests; show a "saving 5 changes" pill.

const pending = useRef(new Map());
const flush = debounce(async () => {
  await api.batch([...pending.current]);
  pending.current.clear();
}, 1000);
// in commit:
pending.current.set(addrKey, next);
flush();

Add a confirmation column — locks the row

Some workflows need explicit "I'm done" — a Save button per row that locks the edited cells. Useful when a row triggers downstream side-effects.

{row.dirty && (
  <Button onClick={() => saveRow(row.id)}>
    Save row
  </Button>
)}
// edits write to row.dirty,
// not the server, until saveRow

Accessibility

Editable tables are notorious — these are the things to get right.

  • Read-mode cells are <button>s. Tab lands on every editable cell. A div + onclick is invisible to keyboard users.
  • Input gets autoFocus. When you open an editor, the input must already have the cursor — no extra click to focus.
  • Errors via aria-describedby. Link the input to its error id; screen readers read the error along with the value.
  • Toast announcements use role="status". Saves don't need to interrupt — a polite live region is enough. Errors use role="alert".
  • aria-keyshortcuts on the table. Document Enter/Esc/Tab/⌘D/⌘Z so users discover them via screen-reader inspection.
  • No invisible focus traps. If the editor is blocked by validation, the user can still Esc out — never lock them in.