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.
A search that survives a session Timeline of a user's actions: search for overdue, step to match 7, sort by date, go to page 3, open a record, press Back; at each point the query and current match are preserved and a single status message is spoken. A search that survives a session0 sSearch "overdue"12 matches announced once8 sFind next ×6current match = invoice INV-1088, cell Status15 sSort by datematches recomputed; still on INV-1088, now "Match 3 of 12"30 sPage 3query kept in the field and the URL; highlights reapplied45 sOpen record, then BackURL restores query; "Search restored: 12 matches" spoken once
The stored identity of the current match is what lets every step re-anchor.

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.

Storing the match by index versus by identity Comparison: storing an index points at a different row after sorting and is wrong after paging; storing row ID and column key re-anchors to the same cell after any reordering. Storing the match by index versus by identity✗ Index ("match 7")Points at a different cell after a sortMeaningless on another page"Find next" restarts from the wrong placeStatus says "Match 7" for a different row✓ Identity (row ID + column)Same cell after any sortResolves to the right page"Find next" continues from the real positionStatus recomputed: "Match 3 of 12"

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 —
Re-anchoring after a sort Chain: the stored identity of the current match is looked up in the recomputed match list, the grid renders, highlights are reapplied, and one position message is spoken. Re-anchoring after a sortStored identityINV-1088, StatuscolumnLookupfind the same rowand columnHighlightreapply marks,outline the currentoneAnnounce"Match 3 of 12"recompute in new orderwait for renderone message
The lookup by identity is the step most implementations skip.

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 with setSearchParams(p, { replace: true }) while typing so history is not flooded.
  • Vue Router — bind the field to route.query.q through a computed setter that calls router.replace({ query }). Use onActivated if 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.

Permalink to "Related"

← Back to Table Search & Match Highlighting