Syncing Filters to the URL with URLSearchParams
After reading this you will be able to wire a filter panel of checkboxes, selects and ranges to the query string so that every combination is a shareable URL, the back button behaves, and no control can erase another control’s value.
← Back to Query String State Sync
Prerequisites
Core Concept
A filter panel is a small state machine whose state should live in the URL rather than in component memory. Doing that gets you four things at once: a shared link reproduces the exact result set, a reload does not reset the view, the back button undoes filter changes in a way users expect, and analytics can see which filter combinations people actually use.
The implementation has one non-obvious rule, and nearly every filter bug comes from breaking it: every write must merge into the current parameters, never rebuild them. A control that constructs a fresh URLSearchParams and sets only its own key silently deletes every other filter, the sort order, and the page number. The symptom — “changing the sort clears my filters” — is reported as a data bug and lives for months.
The second rule is that defaults are omitted. A filter equal to its default contributes nothing to the URL, so ?sort=relevance&archived=0&page=1 collapses to a bare path. This keeps shared links short, makes two equivalent states produce one canonical URL, and means an empty query string is a valid, well-defined state rather than an accident.
The third is that filters use replaceState while pagination uses pushState. A user adjusting four checkboxes does not want four back presses to undo them; a user on page three does expect Back to return to page two.
Implementation
// TypeScript 5.x — a filter model with defaults and codecs, no dependencies
export interface Filters {
q: string;
categories: string[];
minPrice: number;
maxPrice: number;
inStock: boolean;
sort: "relevance" | "price" | "newest";
page: number;
}
export const defaults: Filters = {
q: "",
categories: [],
minPrice: 0,
maxPrice: 10_000,
inStock: false,
sort: "relevance",
page: 1,
};
export function readFilters(search = location.search): Filters {
const p = new URLSearchParams(search);
const int = (key: string, fallback: number) => {
const raw = p.get(key);
return raw !== null && /^\d+$/.test(raw) ? Number(raw) : fallback;
};
return {
q: p.get("q") ?? defaults.q,
// Repeated keys: ?category=a&category=b — order preserved, empties dropped.
categories: p.getAll("category").filter(Boolean),
minPrice: int("min", defaults.minPrice),
maxPrice: int("max", defaults.maxPrice),
inStock: p.get("stock") === "1",
sort: (["relevance", "price", "newest"] as const).find((s) => s === p.get("sort"))
?? defaults.sort,
page: Math.max(1, int("page", defaults.page)),
};
}
Writing merges, drops defaults, and canonicalises:
// TypeScript 5.x
export function writeFilters(patch: Partial<Filters>, mode: "push" | "replace" = "replace"): void {
// Merge base: the CURRENT parameters, so keys owned elsewhere survive.
const p = new URLSearchParams(location.search);
const next = { ...readFilters(), ...patch };
const setOrDelete = (key: string, value: string, isDefault: boolean) =>
isDefault ? p.delete(key) : p.set(key, value);
setOrDelete("q", next.q, next.q === defaults.q);
setOrDelete("min", String(next.minPrice), next.minPrice === defaults.minPrice);
setOrDelete("max", String(next.maxPrice), next.maxPrice === defaults.maxPrice);
setOrDelete("stock", "1", next.inStock === defaults.inStock);
setOrDelete("sort", next.sort, next.sort === defaults.sort);
setOrDelete("page", String(next.page), next.page === defaults.page);
// Repeated keys need delete-then-append; `set` would collapse them to one.
p.delete("category");
for (const c of next.categories) p.append("category", c);
p.sort(); // canonical order → identical states produce identical URLs
const query = p.toString();
const url = `${location.pathname}${query ? `?${query}` : ""}${location.hash}`;
if (url === location.pathname + location.search + location.hash) return; // no-op guard
if (mode === "push") history.pushState(history.state, "", url);
else history.replaceState(history.state, "", url);
}
Changing any filter must also reset pagination, or the user applies a filter and lands on an empty page 7:
// TypeScript 5.x — one entry point for the panel, with the pagination reset built in
export function applyFilter(patch: Partial<Omit<Filters, "page">>): void {
writeFilters({ ...patch, page: 1 }, "replace"); // refinement → replace
render(readFilters());
}
export function goToPage(page: number): void {
writeFilters({ page }, "push"); // movement → push
render(readFilters());
}
// Back and forward re-read from the URL; nothing else is the source of truth.
window.addEventListener("popstate", () => render(readFilters()));
Binding the controls is then mechanical, and — importantly — the controls render from the URL rather than holding their own state:
// TypeScript 5.x — one-way data flow: URL → controls, controls → URL
export function bindPanel(form: HTMLFormElement): void {
form.addEventListener("change", (event) => {
const el = event.target as HTMLInputElement | HTMLSelectElement;
switch (el.name) {
case "category": {
const checked = [...form.querySelectorAll<HTMLInputElement>("[name=category]:checked")];
applyFilter({ categories: checked.map((c) => c.value) });
break;
}
case "stock":
applyFilter({ inStock: (el as HTMLInputElement).checked });
break;
case "sort":
applyFilter({ sort: el.value as Filters["sort"] });
break;
}
});
}
Verification
// @playwright/test v1.44
import { test, expect } from "@playwright/test";
test("filters merge, canonicalise, and respect history policy", async ({ page }) => {
await page.goto("/products");
await page.getByRole("combobox", { name: "Sort" }).selectOption("price");
await page.getByLabel("Laptops").check();
await page.getByLabel("Monitors").check();
await page.getByLabel("In stock only").check();
// Sorted keys, repeated category, defaults absent.
await expect(page).toHaveURL(/\?category=laptops&category=monitors&sort=price&stock=1$/);
// Four refinements, zero extra history entries.
const depth = await page.evaluate(() => history.length);
await page.getByLabel("Monitors").uncheck();
expect(await page.evaluate(() => history.length)).toBe(depth);
// Pagination pushes, and applying a filter resets to page one.
await page.getByRole("link", { name: "Next page" }).click();
await expect(page).toHaveURL(/page=2/);
await page.getByLabel("In stock only").uncheck();
await expect(page).not.toHaveURL(/page=/);
});
test("a shared URL reproduces the exact view", async ({ page, context }) => {
await page.goto("/products?category=laptops&sort=price&stock=1");
const shared = await context.newPage();
await shared.goto(page.url());
await expect(shared.getByLabel("Laptops")).toBeChecked();
await expect(shared.getByRole("combobox", { name: "Sort" })).toHaveValue("price");
});
One further assertion is worth adding once the panel grows past three or four controls: that every filter combination the UI can produce round-trips through the URL unchanged. A short property-style test that generates random filter objects, writes them, reads them back and compares catches the whole class of encoding bugs — a boolean that returns as a string, a category whose value contains the delimiter, a numeric range whose bounds were swapped — without requiring anyone to think of each case individually.
Gotchas
params.setcollapses repeated keys. For multi-value filters you mustdeletethenappendin a loop, or three selected categories become one.- Forgetting to reset the page on a filter change lands the user on an empty page — a filtered result set is almost never as long as the unfiltered one.
- Rebuilding parameters from scratch erases sibling filters. Always construct from
location.search. - Unchecked checkboxes do not appear in a
FormData. Read the checked set explicitly rather than diffing form data, or unchecking the last box does nothing. URLSearchParamsencodes spaces as+. Round-trips are safe, but a log parser or an API that only understands%20will disagree.- Sorting parameters changes the string, not the state. Do it anyway — without it, one logical view produces many different shareable URLs and many different cache entries.
FAQ
Should every filter go in the URL? Every filter a user would share or bookmark, yes. Transient UI — which accordion is open, whether the panel is collapsed on mobile — should not.
Repeated keys or a comma-joined list? Repeated keys are the standards-blessed form and survive values containing commas. A joined list is shorter and easier to read. Pick one per project and apply it consistently; the tradeoffs are covered in encoding arrays and objects in query strings.
What about a free-text search box? Same mechanism, but debounce the URL write so a history entry is not produced per keystroke — see debouncing URL updates while typing.
How do I handle an unknown parameter in an old shared link? Ignore it silently. Throwing on an unrecognised key means every link shared before a filter was renamed becomes a broken page.
Does this work with server-side rendering? Well — the query string is sent to the server, so the same readFilters function can produce the initial state on both sides with no extra plumbing.
Related
- Query String State Sync — the general model this filter panel is one instance of.
- Debouncing URL Updates While Typing — the additional handling a text input needs.
- Encoding Arrays and Objects in Query Strings — choosing a representation for multi-value filters.