Persisting Selection Across Pages and Filters

Permalink to "Persisting Selection Across Pages and Filters"

In a paginated or filtered grid, users often select rows across several pages: tick five invoices on page one, three on page two, filter to another customer and tick two more. If selection is stored by row index or in DOM checkboxes, it vanishes on every page change — or, worse, silently attaches to different rows. If it persists without saying so, a user may run “Archive selected” believing it affects the three visible rows when it affects ten. Screen reader users are most exposed to both failures, because they cannot see checkbox states on other pages.

This guide belongs to bulk selection & batch actions.

Spec reference

Permalink to "Spec reference"
  • SC 4.1.2 Name, Role, Value (A) — each row checkbox exposes its checked state; the select-all checkbox exposes checked, unchecked or mixed for its scope.
  • SC 4.1.3 Status Messages (AA) — selection counts are status messages, especially when hidden rows are included.
  • SC 3.3.4 Error Prevention (AA) — for actions with legal, financial or data consequences, users must be able to review and confirm; stating the full scope of a bulk action is part of that.
  • SC 1.3.1 Info and Relationships (A) — the selection summary should be associated with the grid and the bulk toolbar.
Where selection state lives Layers: the selection store as a set of IDs; the page view rendering checkboxes from the store; the summary bar with visible and hidden counts; the bulk action toolbar whose labels state scope. Where selection state livesSelection storeSet of record IDs; survives paging, sorting, filteringPage viewcheckboxes rendered from the storeSummary"12 selected: 3 on this page, 9 elsewhere"Bulk toolbar"Archive 12 selected invoices…" with confirmation
Selection is data, not DOM; every view reads from it.

When to use — and when not to

Permalink to "When to use — and when not to"

Persist selection when users genuinely build selections across pages or filters — reconciliation, bulk tagging, exports. Make persistence visible: a summary bar that is always shown while anything is selected.

Clear selection automatically only when users would expect it — after a bulk action completes, or when navigating to a different dataset entirely — and announce it. The misapplication: persisting selection silently and offering a bulk action labelled only “Delete” with no count.

Silent persistence versus explicit selection scope Comparison: silent persistence hides how many rows will be affected; explicit scope shows visible and hidden counts and names the scope in every bulk action. Silent persistence versus explicit selection scope✗ Silent persistenceHeader checkbox looks unchecked on page 2"Delete" buttonNo idea rows on other pages are selectedNo reviewAccidental mass actions✓ Explicit scopeHeader checkbox mixed; summary shows 12selected"Delete 12 selected invoices…""3 on this page, 9 on other pages""Review selection" lists all 12Confirmation states the scope

Annotated code example

Permalink to "Annotated code example"
const selected = new Set();                         // record IDs, not indices (SC 4.1.2 via rendered checkboxes)

function renderPage(rows) {
  for (const row of rows) checkboxFor(row.id).checked = selected.has(row.id);
  const onPage = rows.filter(r => selected.has(r.id)).length;
  headerCheckbox.checked = onPage === rows.length && onPage > 0;
  headerCheckbox.indeterminate = onPage > 0 && onPage < rows.length;
  updateSummary(onPage);
}

function updateSummary(onPage) {
  const total = selected.size, elsewhere = total - onPage;
  summary.textContent = total ? `${total} selected: ${onPage} on this page, ${elsewhere} on other pages` : '';
  bulkArchive.textContent = `Archive ${total} selected invoices…`;               // scope in the name (SC 3.3.4)
  bulkArchive.hidden = total === 0;
}

function toggle(id, on) {
  on ? selected.add(id) : selected.delete(id);
  renderPage(currentRows);
  status.textContent = `${selected.size} selected`;                              // SC 4.1.3
}

selectAllMatching.addEventListener('click', async () => {
  const ids = await api.idsMatching(currentFilters);                           // explicit, counted
  ids.forEach(id => selected.add(id));
  renderPage(currentRows);
  status.textContent = `All ${ids.length} matching invoices selected`;
});

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Event Expected announcement AT deviations
Space on a row checkbox “Select INV-1042, checked” then “4 selected” —
Page 2 with selections elsewhere Header checkbox: “Select all on this page, checkbox, not checked” Mixed announced as “partially checked”
Tab to summary “12 selected: 3 on this page, 9 on other pages” Summary text read
Activate bulk action Confirmation dialog: “Archive 12 invoices?” —
Select all matching “All 240 matching invoices selected” —
A cross-page selection and bulk action Sequence among user, page 1, page 2, summary and confirmation: the user selects 3 rows on page 1, pages forward and selects 2 more; the summary shows 5 selected with 3 elsewhere; the bulk action names 5 and asks for confirmation. A cross-page selection and bulk actionUserGridSummaryConfirmationselect 3 rows on page 1next page; select 2"5 selected: 2 on thispage, 3 on other pages"Archive 5 selected invoices…"Archive 5 invoices? 3 are on other pages."
Scope is visible and confirmed before anything changes.

Integration context

Permalink to "Integration context"

Select-all scope and indeterminate state are covered in implementing select all with indeterminate checkbox state, and announcements in announcing selection count changes in data grids. The bulk toolbar follows contextual bulk action toolbars. Search state persistence uses the same identity principle — see persisting search state across pages and sorts.

Gotchas

Permalink to "Gotchas"

Filtered-out selections. When a filter hides selected rows, keep them selected but say so: “9 selected rows are hidden by the current filter”.

Deleted records. If a selected record is deleted elsewhere, remove it from the selection and update the count.

Server pagination. “Select all on this page” and “select all matching” are different scopes; never conflate them.

Worked example

Permalink to "Worked example"

A finance team bulk-approves expenses across pages. Their grid stored selection in DOM checkboxes, so paging lost it, and a “Select all” header checkbox silently meant “all 2,400 matching” in one view and “this page” in another. After moving selection to an ID set, adding a persistent summary (“14 selected: 4 on this page, 10 on other pages”), naming bulk buttons with counts and adding an explicit “Select all 2,400 matching” link, mis-approvals stopped and a screen reader user could build and review selections confidently.

Common mistakes

Permalink to "Common mistakes"
  • Selection stored by index or DOM state.
  • Header checkbox scope that changes between views.
  • Bulk buttons without counts.
  • No review of hidden selected rows.

Testing checklist

Permalink to "Testing checklist"

Design system notes

Permalink to "Design system notes"

Make selection a grid-level store keyed by rowId, with built-in summary and scope-aware bulk toolbar. Product teams should not reimplement cross-page selection.

Step-by-step implementation

Permalink to "Step-by-step implementation"
  1. Store selection by stable ID. Keep a Set of record IDs rather than row indexes, so selections survive sorting, paging and filtering. Row indexes change the moment the view changes; IDs do not.
  2. Derive checkbox state from the set. When a page renders, each row checkbox reads selected.has(row.id). The header checkbox becomes mixed when some but not all visible rows are selected, and its accessible name should say what it covers: “Select all 25 rows on this page”.
  3. Render a persistent selection summary. Outside the table, show “12 selected across 3 pages” with a “Show selected” and a “Clear selection” control. This summary is the only place where off-screen selections become visible, so it must never scroll away with the rows.
  4. Announce changes politely. After a toggle, update a polite live region with the new total. Do not announce on page change unless the count changed.
  5. Reconcile on filter. When a filter hides selected rows, keep them selected but say so: “12 selected, 4 hidden by the current filter”. Batch actions should state exactly which rows they will affect before running.

Edge cases

Permalink to "Edge cases"
  • Deleted records. If a selected record is deleted by another user, drop it from the set on the next fetch and update the summary; do not let a batch action fail silently on a missing ID.
  • Select all matching. “Select all 2,431 matching rows” is a different state from a set of IDs; represent it as a query plus exclusions, and say so in the summary.
  • Session expiry. Decide whether selection survives reloads. If it does, restore the summary on load so users are not surprised by a pre-filled batch.

FAQ

Permalink to "FAQ"
Should selection persist across pages by default?

For grids used for bulk work, yes, with a visible summary. For simple lists, clearing on page change can be clearer — be consistent.

What should the header checkbox select?

Rows on the current page. Offer “select all matching” separately.

How do users review selections on other pages?

A “Review selection” view or filter that shows only selected rows.

Should selection survive navigation away and back?

If the task spans visits, store it in the URL or session; otherwise clear it and say so on return.

How large can a selection be?

As large as the backend supports; for “all matching”, operate on the filter rather than shipping thousands of IDs.

How do I announce hidden selections?

Include them in the summary text and in bulk action names and confirmations.

Permalink to "Related"

← Back to Bulk Selection & Batch Actions