Skip to content

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.

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.

NamePriceShip date
Chai18.002026-08-01
Chang19.002026-08-03
<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;
}
});

PATCH /items/:id (col, value):

CaseResponse
accepted200 + 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
rejected422 + 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 confirmation200 + the record <tbody> in the confirm-pending state — see below. Nothing is committed
unknown row / column404 — 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.

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.

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.

  • The message announces via role="alert" on an inner element (the cell keeps its gridcell role); the rejected cell is linked with aria-invalid + aria-describedby.
  • Saving is aria-busy on 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.
  • 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.