Datagrid edit errors
The datagrid’s inline
commit is optimistic — this recipe closes its feedback loop for
values only the server can judge. Each row is its own
record <tbody> carrying the persistence wiring; the server’s
answer re-renders that record: 200 confirms with the row alone,
422 brings the row (server value restored, cell marked
data-invalid) plus the .hc-datagrid__error-row message — one
atomic swap unit, so a stale error can never be stranded. Covered by
the versioning policy.
Live demo
Section titled “Live demo”Double-click a Price cell (or focus it and press Enter)
and commit abc — the cell flashes the saving state, then snaps back
to the server’s value with the error ring, and the message names your
rejected input. Commit a number greater than 0 to see the 200 branch
confirm and clear it.
| Name | Price | Ship date |
|---|---|---|
| Chai | 18.00 | 2026-08-01 |
| Chang | 19.00 | 2026-08-03 |
The markup
Section titled “The markup”<div class="hc-datagrid" id="items-grid" data-hc-datagrid-pending> <template data-datagrid-editor data-col="price"> <input class="hc-input" type="text" inputmode="decimal" aria-label="Price"> </template> <div class="hc-datagrid__scroll"> <table class="hc-datagrid__table"> <thead class="hc-datagrid__head">…</thead> <tbody class="hc-datagrid__record" id="item-1" data-hx-patch="/items/1" data-hx-trigger="hc:datagridedit" data-hx-vals="js:{ col: event.detail.col, value: event.detail.value }" data-hx-disinherit="hx-vals" data-hx-swap="outerHTML"> <tr class="hc-datagrid__row"> <td class="hc-datagrid__cell">Chai</td> <td class="hc-datagrid__cell" data-numeric data-editable data-col="price" data-value="18">18.00</td> </tr> </tbody> </table> </div></div>data-hc-datagrid-pending opts the grid into the saving state
(data-pending + aria-busy on the cell until the re-render lands).
Allow 422 swaps once, globally — the
field-errors allowance:
document.body.addEventListener('htmx:beforeSwap', (event) => { if (event.detail.xhr.status === 422) { event.detail.shouldSwap = true; event.detail.isError = false; }});Server response contract
Section titled “Server response contract”PATCH /items/:id (col, value):
| Case | Response |
|---|---|
| accepted | 200 + the record <tbody> — the row alone, the cell showing the server’s formatting of the value; confirms the optimistic commit, clears data-pending, and atomically removes any previous error row |
| rejected | 422 + the record <tbody> — the cell back on the server’s current value, data-invalid + aria-invalid + aria-describedby, followed by the __error-row whose role="alert" message names the rejected input |
| needs confirmation | 200 + the record <tbody> in the confirm-pending state — see below. Nothing is committed |
| unknown row / column | 404 — nothing swaps; the standard error toast covers it |
Cancel needs one more route: GET /items/:id → the stored record,
exactly as it was. Nothing was written, so there is nothing to undo.
The record tbody is the atom: row and error row always travel together — no OOB bookkeeping, stateless server, and the cell never shows a value the server has not vouched for.
Confirmable warnings
Section titled “Confirmable warnings”Some values are acceptable but unusual: a ship date in the future,
a discount above policy, a quantity ten times the usual. They are not
errors — 422 would tell the user to change something that needs no
changing — and only the server knows the rule, so a client-side confirm
(installConfirm) cannot express it: the rule is discovered on the way
in, after the user has already committed.
200 is the honest answer. Nothing failed and nothing was rejected;
the server is continuing the conversation. It also needs no
htmx:beforeSwap allowance.
<td class="hc-datagrid__cell" data-editable data-col="ship" data-value="2027-01-01" data-attention="warning" aria-describedby="item-2-note">2027-01-01</td>…<tr class="hc-datagrid__error-row"> <td class="hc-datagrid__error" data-tone="warning" colspan="3"> <span role="alert" id="item-2-note">2027-01-01 is in the future. Confirm to ship Chang on that date.</span> <button class="hc-button" data-size="sm" data-variant="primary" type="button" data-hx-patch="/items/2" data-hx-vals='{"col":"ship","value":"2027-01-01","confirm":"9f2c1a"}' data-hx-target="closest tbody" data-hx-swap="outerHTML">Confirm</button> <button class="hc-button" data-size="sm" type="button" data-hx-get="/items/2" data-hx-target="closest tbody" data-hx-swap="outerHTML">Cancel</button> </td></tr>The cell shows the proposed value — the user cannot confirm what
they cannot see — marked data-attention="warning", and the record
carries the same attribute so the row reads as needing the user
whatever tint is painted over it.
Bind the token to the value. confirm is a single-use token issued
for one (row, column, value) — and in a versioned store, one
version too. Without that binding, a confirmation obtained for one
value could commit a different one, and the 409 version guard is
bypassed by replay. A server that only checks confirm=1 has built a
confused deputy, not a confirmation. The buttons use static
data-hx-vals (not js:), so the value being confirmed is pinned at
render time and stays CSP-safe.
Bulk operations already have this shape: the datagrid-bulk-errors pre-flight (“18 can proceed, 2 cannot”) is the same conversation for many rows at once.
Progressive enhancement
Section titled “Progressive enhancement”Inline cell editing is itself a JavaScript enhancement; the no-JS path is the inline-edit recipe’s page-level form. The grid renders and reads fine without any script.
Accessibility
Section titled “Accessibility”- The message announces via
role="alert"on an inner element (the cell keeps itsgridcellrole); the rejected cell is linked witharia-invalid+aria-describedby. - Saving is
aria-busyon one cell — the rest of the grid stays operable; the error row stays out of keyboard navigation. - Focus survives the swap; Enter re-edits in place.
- The confirm row sits outside the navigation matrix, so its buttons
keep their natural tab order;
role="alert"announces the question without stealing focus.
Related
Section titled “Related”- Datagrid — the edit
lifecycle states this recipe drives (
data-pending,data-invalid, the error-row slot). - field-errors — the same 422 philosophy for whole forms.
- inline-edit — the single-field click-to-edit sibling.