Pattern: bulk actions
Who uses it: an AP clerk clearing a queue — select many, act once. What "done" looks like: every selected record either changed or explained why it didn't. The failure mode this pattern exists to prevent is the silent one: an action that reports success while quietly skipping rows. Not for changing one record — a single row's fields belong on the object page or record-detail; this pattern's whole cost (out-of-band summary, per-row reasons, the transient-state rule) only pays for itself once more than one record is changing at once.
The state after a partial failure
2 approved, 2 could not be. The rows below carry the reason.
| Invoice # | Vendor | Amount | Status |
|---|---|---|---|
| INV-10234 | Acme Supply Co. | $4,208.00 | Approved |
| INV-10235 | Globex Industrial | $56,000.00 | Pending Needs a second approver over $20,000.00 |
| INV-10236 | Initech GmbH | $730.25 | Approved |
| INV-10237 | Umbrella Logistics | $2,110.00 | Rejected Already rejected |
Zero new components: the summary is an
alert, the failed rows use
data-row-state="error", and each reason is a
badge — in words, beside the
status, so the tint is never the only signal. A clerk can see which
two rows need them and why without opening anything.
Anatomy
- Selection (not in the demo above — see the list report for the live checkboxes) — row checkboxes carrying
name/value, so the selection POSTs natively;initDataTables()keeps the count O(1) at scale. - Toolbar action (same — see the list report) — bulk buttons appear only when something is selected (toolbar).
- Summary region — an empty container above the table that the response fills out of band.
- Per-row result — the error state plus the reason, rendered by the same row template as everything else.
Data contract
| Request | Returns |
|---|---|
| POST /invoices/bulk-approve (ids as repeated form fields) | the affected <tbody> plus an out-of-band summary block — one request, both truths |
| …all succeeded | same shape, a success alert; do not special-case the happy path into a different mechanism |
| …nothing selected | the button should not have been reachable; if it was, return the list unchanged and say so |
| …some rows fail (partial or total) | still 200, not a 4xx — per-row failure is data (the States table's Partial/All failed rows), never a transport-level error; a genuine 5xx or timeout is the one case that IS a failure response, and gets the plain retry-alert treatment, same as any other request on this screen |
<!-- The response to a bulk POST does TWO things: swap the rows, and
deliver the summary out of band. One request, both truths. -->
<tbody id="rows">
<tr id="row-INV-10235" data-row-state="error">
…cells…
<td>
<span class="bo-badge bo-badge--warning">Pending</span>
<!-- the reason in WORDS — the row tint is never the only signal -->
<span class="bo-badge bo-badge--danger">Needs a second approver over $20,000.00</span>
</td>
</tr>
</tbody>
<div id="bulk-result" hx-swap-oob="innerHTML">
<div class="bo-alert bo-alert--warning" role="alert">
<p><strong>18 approved, 2 could not be.</strong> The rows below carry the reason.</p>
</div>
</div> Selection is client state: a swap that replaces rows clears it, which is the honest behaviour after a bulk action — the user's next decision should start from the new truth.
The rule that makes it honest
// Partial-failure state is TRANSIENT. Clear last round's reasons before
// computing this one, or a row keeps advertising an error that is no
// longer true — the list starts lying about the record.
records.forEach((r) => { delete r.bulkError; });
const failed = [];
let ok = 0;
for (const r of selected) {
if (r.status !== "Pending") { failed.push([r, "Already " + r.status.toLowerCase()]); continue; }
if (r.amount > LIMIT) { failed.push([r, "Needs a second approver"]); continue; }
r.status = "Approved"; ok += 1;
}
for (const [r, reason] of failed) r.bulkError = reason; // rendered per row Found by building this against the reference app: without the clear, a row kept showing "needs a second approver" after a colleague had approved it. A stale failure reason is worse than none — it sends someone to fix a problem that no longer exists.
Mass change — the honest answer to "update 200 records"
Select N rows, set one field, in one validated operation. This is the request that usually arrives as "we need editable cells", and it is worth pushing back on: 200 hand-edits is 200 chances to fumble a value, and it leaves no record of what was intended. One operation is simpler and safer, and it produces a single audit entry.
<!-- ONE form, ONE selection, two endpoints. formaction re-points the same
checked ids at the mass-change handler, so there is no second selection
to keep in sync. The safe action stays the form's submit button, so the
keyboard path (Enter from any row) still cannot reach a destructive one. -->
<form id="po-bulk" hx-post="/pos/bulk-approve" hx-target="#rows" hx-swap="outerHTML">
<div class="bo-data-table__bulk-actions" role="group" aria-label="Bulk actions">
<button class="bo-btn" type="submit">Approve selected</button>
<button class="bo-btn bo-btn--secondary" type="button"
data-dialog-trigger="mass-cc">Change cost centre…</button>
</div>
…table with name="id" row checkboxes…
</form>
<!-- The dialog's field JOINS the form above via form="po-bulk"; its submit
button re-targets with formaction. -->
<input class="bo-input bo-input--code" name="cc" form="po-bulk" placeholder="CC-4021">
<button class="bo-btn" type="submit" form="po-bulk"
formaction="/pos/mass-change">Change selected</button> Same contract as the action above — rows plus an
out-of-band summary, per-row reasons in words, the TRANSIENT-state rule.
No new component and no second selection: formaction re-points the same checked ids at a different
endpoint, and the dialog's field joins the table's form with
form="po-bulk".
Validate the operation before you touch a row
- A bad target value is not a per-row failure.
"CC-nonsense" is the whole request being wrong, so it returns
422with a document-level message and changes nothing — not 200 identical row errors the user has to read past. - Per-row rules still apply per row. A purchase order that is already approved needs a reversal, not a re-cost, so it is reported in its own row with that reason while the others go through.
- Say the count and the value. "1 moved to CC-9002, 1 could not be" — a bare "done" hides which half happened.
- No-op rows are failures worth naming. A row already on the target cost centre is reported ("Already on CC-9002") rather than counted as changed, because a count that includes no-ops is a lie about what the operation did.
This is M3 of the four data
maintenance patterns in DESIGN.md; the working implementation
is in
examples/po-app.
States
| State | What ships |
|---|---|
| All succeeded | a success alert with the count; rows re-render in their new status |
| Partial failure | counts BOTH ways in one sentence, and every failed row carries its own reason |
| All failed | same shape — never a bare "action failed" with no per-row detail |
| Nothing selected | bulk actions are hidden until a selection exists, so this should be unreachable |
| No permission | the bulk buttons are absent, not disabled (permissions) |
| Stale selection | a record decided by someone else since the page loaded fails with "already approved" — that is the partial-failure path doing its job, not an error |
| Long-running | past a few seconds, return a job id and poll; a frozen button is not a progress indicator |
| No JS | fully functional — this page has no JS dependency; the rows, checkboxes and bulk buttons in the real screen are plain form controls that POST natively (see the Markup section's hx-post sample — the underlying <form> submits without htmx too). What is lost without JS is the out-of-band summary swap and htmx's per-id focus restoration described in the keyboard walkthrough; a plain form submit reloads the page with the same result instead of swapping in place |
Keyboard walkthrough
- Tab to the select-all checkbox, Space to take the page; the live region announces the count.
- Tab through rows, Space to deselect the ones you are not ready for.
- Back to the toolbar, where the first selection has revealed the bulk
Enter, from wherever you are in the list — the
rows and the bulk buttons are one
<form>, so native implicit submission runs the action without any journey back to the toolbar. Only the safe action istype="submit"; a destructive bulk action staystype="button"and needs a deliberate click. - Focus stays on the checkbox you pressed — but only because each one
carries an
id. The swap replaces those very rows, and htmx restores focus solely to a matching id; without it the clerk is dropped on<body>at the top of the document. - The summary is
role="alert", so the outcome is announced without moving focus; the failed rows are still in document order below it.
Components used
complexity 3 of 4