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.
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.
Stock count · Park Centre
Enter saves · Esc reverts · Tab next cell
Stock count · Park Centre
Saving · optimistic paint
Stock count · Park Centre
Qty can't be negative · Enter to retry, Esc to revert
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
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.
saving, errors: maps — multiple cells can be in flight.
Switch to the Code tab to see the snippet.
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.
Save OK → clear(rowId) (the server now matches).
Save 5xx → rollback(rowId) + Toast retry.
Switch to the Code tab to see the snippet.
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.
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.
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:
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.
5xx (network) → rollback row, Toast "Retry".
4xx (validation) → keep optimistic value, mark cell error.
Switch to the Code tab to see the snippet.
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.
Saving is a visual tint on the view branch, not its own render path.
Switch to the Code tab to see the snippet.
Final composition
The StockTable, assembled. Cell state machine, optimistic patches, keyboard contract, error recovery.
Stock count · Park Centre
Enter saves · Esc reverts · Tab next cell
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-invalidandaria-describedbypointing 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
Toastwith a "Retry" button;Toastis rendered inside anaria-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.