Persisting Search State Across Pages and Sorts
Permalink to "Persisting Search State Across Pages and Sorts"Search state is easy to lose. The user finds the seventh of twelve matches, sorts by date to see context, and the highlight disappears — or worse, it is still there but “Find next” jumps to the first match in the new order. Paginate, and the query field empties. Open a record and press Back, and the grid reloads unsearched. Each of these is a small irritation for a mouse user and a disorientation for a screen reader user, who cannot glance at the grid to see what changed.
This guide shows how to keep the query, the current match and the spoken status consistent across sorts, pages and navigation. It is part of table search and match highlighting.
Spec reference
Permalink to "Spec reference"- SC 3.2.3 Consistent Navigation / 3.2.2 On Input (A, AA) — operations such as sorting should not silently reset other settings the user made.
- SC 2.4.3 Focus Order (A) — after a sort, focus and the current match should remain on the same logical item.
- SC 4.1.3 Status Messages (AA) — when state is restored (for example after Back), a single status message confirms it.
- SC 3.3.7 Redundant Entry (A, WCAG 2.2) — information the user already entered in the same process should not need re-entering; a lost query after pagination is a mild case of exactly this.
When to use — and when not to
Permalink to "When to use — and when not to"Persist search state whenever the grid is part of a workflow where users move away and come back: triage queues, ledgers, admin lists. Store it in the URL query string so that Back, Forward, reload and shared links all reproduce it.
Do not persist across unrelated sessions without telling the user. A query silently restored from last week’s localStorage makes a grid look half-empty for no visible reason. If you restore from storage, show the query in the field and announce “Search restored”.
The misapplication is storing the current match as an index (“match 7”). Indices change with every sort and filter; store the row ID and column key instead.
Annotated code example
Permalink to "Annotated code example"// State lives in the URL so Back/Forward/reload restore it (SC 3.3.7 spirit)
function writeState({ q, matchRow, matchCol, sort, page }) {
const p = new URLSearchParams(location.search);
q ? p.set('q', q) : p.delete('q');
matchRow ? p.set('m', `${matchRow}:${matchCol}`) : p.delete('m'); // identity, not index
p.set('sort', sort); p.set('page', page);
history.replaceState(null, '', `?${p}`);
}
async function afterGridChange() { // call after sort, filter or page change
matches = computeMatches(data, query); // reading order of the NEW arrangement
const i = matches.findIndex(m => m.rowId === current?.rowId && m.col === current?.col);
currentIndex = i >= 0 ? i : nearestToFocus(matches);
await grid.rendered(); // wait for DOM before highlighting
applyHighlights(matches, currentIndex);
// Announce position only if the user is mid-search; otherwise stay quiet
if (query) status.textContent = `Match ${currentIndex + 1} of ${matches.length}`;
}
window.addEventListener('pageshow', async () => { // includes bfcache restores
const q = new URLSearchParams(location.search).get('q');
if (!q) return;
input.value = q;
await afterGridChange();
status.textContent = `Search restored: ${matches.length} matches for “${q}”`; // once (SC 4.1.3)
});
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Expected announcement | Deviation |
|---|---|---|
| Sort while a match is current | Sort confirmation, then “Match 3 of 12” | JAWS may speak only the sort message; keep both short |
| Next page | Page change message; highlights reapplied silently | — |
| Back to the grid | “Search restored: 12 matches for ‘overdue’” | Safari bfcache restores without load; use pageshow |
| Clear search button | “Search cleared”, focus returns to field | — |
Integration context
Permalink to "Integration context"Restoring focus and scroll position on Back has its own guide — restoring focus and scroll on browser back — and the techniques combine: restore the query first, then the focus. When sorting, follow the announcement wording from multi-column sort announcement patterns and append the match position after it, not before.
Gotchas
Permalink to "Gotchas"Two messages in one tick. Sort confirmation and match position written at the same moment collide. Use an announcement queue or combine them: “Sorted by date, newest first. Match 3 of 12.”
pushState per keystroke. Writing every keystroke as a new history entry makes Back step through partial queries. Use replaceState while typing.
Server-side search. If matches are computed on the server per page, you cannot know the global position. Announce “Match 3 of 12 on this page, 40 in total” rather than a misleading single number.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Make search state part of the grid’s serialisable state object alongside sort, filters and page, so routing libraries can store and restore all of it together. A single gridState URL codec shared across products prevents the slow drift where each team persists a different subset.
Framework notes
Permalink to "Framework notes"Routers differ in how they restore state, and that decides where the search query should live:
- React Router / Next.js — read the query with
useSearchParams()and treat the URL as the source of truth. Write withsetSearchParams(p, { replace: true })while typing so history is not flooded. - Vue Router — bind the field to
route.query.qthrough a computed setter that callsrouter.replace({ query }). UseonActivatedif the grid lives inside<KeepAlive>, because a cached component does not remount on Back. - Angular Router — subscribe to
ActivatedRoute.queryParamMap, and navigate with{ queryParamsHandling: 'merge', replaceUrl: true }.
// Vue 3: query string as the single source of truth for the search field
const q = computed({
get: () => route.query.q ?? '',
set: (v) => router.replace({ query: { ...route.query, q: v || undefined } }),
});
In all three, announce restoration from the same place that re-applies highlights — after the grid reports it has rendered — so the message never describes a grid that is still empty.
FAQ
Permalink to "FAQ"Should a search query survive a full page reload?
Yes, if it is in the URL — that is what users expect from Back and from shared links. Restoring from local storage on a fresh visit is more surprising and should be announced.
What if the current match no longer exists after a filter?
Move to the nearest remaining match after the focused row and announce the new position. If there are none, announce zero matches and keep focus in place.
Should restoring a search announce anything?
Once, briefly: “Search restored: 12 matches”. Without it, screen reader users have no way to know the grid is highlighting anything.
Should sort and page also go into the URL?
Yes. Search state on its own is not enough to reproduce what the user saw: the same query on a different page or in a different order lands on different matches. Store sort, filters, page and the search query together, and restore them in that order before re-anchoring the current match.
Related
Permalink to "Related"- Find next and previous — the position the state preserves
- Restoring focus and scroll on Back — the companion technique
- Persisting selection across pages — the same problem for selections