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.
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.
Stock prices
Stock prices
Stock prices
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
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.
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.
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.
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.
Validators take the whole row, not just the value — for cross-field rules ("Qty × Price ≤ stock cap"). One file lists every validator.
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.
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.
Final composition
~200 lines: state machine, cell renderer, validation, optimistic save, fill-down, undo.
| SKU | Qty | Price |
|---|---|---|
| Rebar 12mm | 240 | |
| Cement OPC | 120 | $1,200 |
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 errorid; 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 userole="alert". aria-keyshortcutson 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.