Datagrid snapshot pager
With a live re-query pager (datagrid pager) a “pending approval” search of 56 items pages honestly — until the user approves 10 on page 1. The pending set is now 46; page 2 is sliced from the new set, so ten formerly-page-2 rows slide up onto page 1, which the user has already left. In a work queue that’s a missed-item bug, not a UI nicety.
This recipe freezes the queue’s membership at search time while each row’s state stays live: the form carries every hit’s row key, paging re-fetches exactly those rows in exactly that order, approved rows render approved, and nothing moves until a new search. Zero new JS — the DOM is the snapshot store, native form serialization is the wire format.
Live demo
Section titled “Live demo”A 56-item approval queue, 20 per page. Select a few rows on page 1 and
Approve them — the count stays “of 56 (as of search)”, the rows
stay put, and paging away and back shows them still approved.
Withdraw simulates rows vanishing from the queue (deleted or
reassigned elsewhere): they page back in as tombstones. The endpoint
is a namespaced demo implementation of the contract under
api/recipes/datagrid-snapshot-pager/ (a real server keeps row state
in its database; the stateless demo threads it through the opaque key
tokens, and so re-renders the keys block out of band with the mutated
tokens after each action — something a stateful server never needs to
do).
The markup
Section titled “The markup”One <form method="post"> wraps everything:
<form method="post" action="/approvals/page"> <!-- The snapshot: one hidden input per hit, in display order, minted by the server at search time. OUTSIDE the swap target. --> <input type="hidden" name="keys" value="tok_a1"> <input type="hidden" name="keys" value="tok_b2"> <!-- …one per hit… --> <!-- The current page, updated out-of-band by each page response: --> <input type="hidden" name="page" value="1" id="page-field">
<div class="hc-datagrid"> <div class="hc-datagrid__scroll"> <table class="hc-datagrid__table"> <thead class="hc-datagrid__head">…</thead> <tbody class="hc-datagrid__body" id="rows"><!-- page 1, server-rendered --></tbody> </table> </div> </div>
<p id="rows-status" aria-live="polite">1–20 of 56 (as of search)</p>
<nav class="hc-pagination" id="pager" aria-label="Pagination"> <button class="hc-pagination__item" type="submit" name="page" value="1" data-hx-post="/approvals/page" data-hx-target="#rows" data-hx-swap="innerHTML" aria-current="page">1</button> <button class="hc-pagination__item" type="submit" name="page" value="2" data-hx-post="/approvals/page" data-hx-target="#rows" data-hx-swap="innerHTML">2</button> <button class="hc-pagination__item" type="submit" name="page" value="3" data-hx-post="/approvals/page" data-hx-target="#rows" data-hx-swap="innerHTML">3</button> </nav></form>Why each piece is the way it is:
- The keys are hidden inputs, in display order. Form serialization walks submittable elements in tree order (the HTML entry-list algorithm — the sortable recipe rests on the same guarantee), so the wire order is the display order. No client JS assembles anything.
- Keys are opaque, server-minted tokens. A composite primary key
folds into one token (
base64url(JSON)of the key columns, or a surrogate id); the client echoes tokens verbatim and never composes, parses, or delimits them. keysvsids.keysis the snapshot membership — hidden inputs.ids(from datagrid bulk actions) is the rows selected for an action — checkboxes. They coexist in this form and must never share a name.- Pager buttons, not links — the snapshot travels in the POST body.
Buttons swap
innerHTMLof the tbody, per the datagrid pager swap rules. - The hidden
pagefield is updated out-of-band by every page response, so action requests (not triggered by a pager button) know which page to re-render. On a pager click both serialize — the button is the later entry, so the server reads the lastpagevalue.
Server response contract
Section titled “Server response contract”POST /approvals/page receives every key (in order) plus page. The
server must:
- Validate the count — a
keyslist over the search cap (see result cap) is a broken client:422, never a truncated page. - Re-check authorization for every key, every time — keys arrive from the client; membership in a past search result proves nothing.
- Slice the page server-side — the client never slices.
- Return rows in received-
keysorder.WHERE key IN (…)guarantees nothing: join an ordinal (VALUES … ORDER BY ord,unnest WITH ORDINALITY,ORDER BY FIELD(…)) or reorder in the app via a key→row map — whose misses are exactly the tombstones. - Render vanished rows as tombstones (“No longer in this queue”), keeping the page arithmetic and the user’s mental count intact.
- Render current state — approved rows render approved
(badge, checkbox
disabled), not pending-as-of-search.
The response is the page’s <tr> rows plus out-of-band fragments:
<tr class="hc-datagrid__row">…</tr><!-- …one per key in the page slice, in order, tombstones included… -->
<nav class="hc-pagination" id="pager" data-hx-swap-oob="true" aria-label="Pagination"> …buttons, aria-current="page" moved…</nav><p id="rows-status" data-hx-swap-oob="true" aria-live="polite">21–40 of 56 (as of search) — 10 approved</p><input type="hidden" name="page" value="2" id="page-field" data-hx-swap-oob="true">| Request | Response |
|---|---|
POST /approvals/page (keys[] + page) | 200 + that page’s rows in keys order + OOB pager / status / page-field |
POST /approvals/approve (ids[] + action; keys + page ride along) | 200 + the current page re-rendered, processed rows in their new state, keys untouched |
keys[] over the cap | 422 — broken client, never a truncated page |
Composing with bulk actions
Section titled “Composing with bulk actions”Wrap the selection bar, the grid, and the pager in the same form (see
expanded.html): the action button POSTs ids + action, and keys
plus the hidden page ride along by native serialization. The
response re-renders the current page’s rows and leaves the keys
inputs alone — the queue still shows 56 items on 3 pages afterwards.
Only the current page’s rows exist in the DOM, so ids can only name
current-page rows.
Progressive enhancement
Section titled “Progressive enhancement”The pager buttons are native submits: without htmx the form POSTs and
the server (no HX-Request) renders the full page. Page 1 and the
snapshot are server-rendered at search time, so the queue is complete
before any JavaScript runs. (On this static docs site the demo
fetches its snapshot with a load trigger instead; a real server
renders the search response inline.)
Accessibility
Section titled “Accessibility”- Pager semantics follow the datagrid pager:
<nav aria-label="Pagination">,aria-current="page"on the active button. - The status line is
aria-live="polite"and announces page changes and processing progress (”… — 10 approved”). - Tombstone rows keep their identity cell — the user can still see which item left — and state the reason in text.
- Scope: work queues. Cap the search at 500–1,000 keys
(result cap’s
hard-reject mode) and the keys payload stays tens of KB. For
unbounded lookup screens use the live re-query
datagrid pager;
if you need snapshot semantics without a key list, an
as_oftimestamp threaded through the query (pending OR processed_after(as_of)) is the stateless alternative. - Reload = new search. The snapshot lives in the DOM — there is no URL for “page 2 of this snapshot”. Bookmarking and sharing are deliberately out; document it, don’t fight it.
- Sort or filter change = new snapshot (a fresh search response
with fresh keys). A
sizechange (optional field) resets to page 1.
Related
Section titled “Related”- Datagrid pager — the live re-query pager this one deliberately isn’t.
- Result cap — bounds the snapshot before it exists.
- Datagrid bulk actions — the action half of the composed form.
- Sortable list — the same tree-order serialization guarantee, used for reordering.