MDN URLSearchParams - discussion (2022-09-17)
For filter/search-heavy UIs: we keep oscillating between “query params are the truth” and “query params are just a serialization format” and it's showing up as weird back/forward behavior.
Do you treat search params as route truth (and derive everything else), or keep a route doc shape as the real contract? How do you model multi-select params (repeated keys vs comma-separated) so it stays stable across refactors? What do you render in the UI so support can understand the current filter posture from a screenshot?
Comments (10)
Back to latestPick one truth. The bugs start when you “sync” after render.
We treat params as a transport and the route doc as the contract shape.
The URL is a hint; the doc stores a normalized { q, tags, sort } so the component tree reads one stable surface.
Related: Routing and Navigation and the “URL as hint” posture in Core Concepts Best Practices.
A small normalization layer goes a long way. We parse wide params into a narrow contract and render it:
ts
export function parseFilters(params: URLSearchParams) {
const q = params.get('q') ?? '';
const tags = params.getAll('tag');
const sort = params.get('sort') ?? 'relevance';
return { q, tags, sort } as const;
}
export function writeFilters(params: URLSearchParams, next: { q: string; tags: string[]; sort: string }) {
params.set('q', next.q);
params.delete('tag');
next.tags.forEach((t) => params.append('tag', t));
params.set('sort', next.sort);
}Counterpoint: repeated keys (tag=a&tag=b) are stable but they’re annoying to diff and copy/paste for non-technical users.
We used comma-separated tags for a while because product wanted “pretty URLs”, then spent months handling escaping edge cases.
Yeah, pretty URLs are seductive.
The repeated-key approach is the only one where we never had to explain “why did this tag disappear on back/forward”.
Agreed. If it’s a contract, choose stability over aesthetics.
The best thing we did was render the normalized filter contract in a dev-only panel.
Once you can *see* { q, tags, sort }, you stop arguing about whether the URL is lying.
Don’t keep filter state in local component state if back/forward needs to reproduce it.
Either make URL truth or make route doc truth, but don’t build a third truth in a hook.
We avoid URL-sync effects by letting navigation own the write, not an effect.
tsx
const hint = useRouteState({ q: '', sort: 'relevance' });
const doc = useRouteDoc('search');
doc.write('filters', { q: hint.read('q'), sort: hint.read('sort') });
return <div data-filters={JSON.stringify(doc.read('filters'))} />;
The key is there’s no “when doc changes, update URL” effect. The URL is only written on intent.
Also: watch out for empty values. ?q= vs missing q can cause subtle diffs if you treat the raw params as meaning.
Normalize aggressively and render the normalized shape.
If you have a “default sort” and you don’t render it, you’ll get “why did it change” tickets.
Even a tiny <div data-sort='relevance' /> is enough to make it debuggable.
One last tip: if product wants pretty URLs, keep pretty on the outside and boring on the inside.
Parse into a contract, then only render from the contract.