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.

Merging a filter change into existing parameters A filter control produces a patch containing only its own key. The writer reads the current parameters, applies the patch, drops any key equal to its default, sorts the result, and writes it back — preserving the sort order and page number that other controls own. One control, one key — everything else must survive checkbox toggled patch: { archived: true } read current params ?sort=price&page=3 the merge base apply + drop defaults removed keys sorted replaceState no new entry Rebuilding instead of merging new URLSearchParams() → set("archived", "1") result: ?archived=1 sort and page silently erased Merging new URLSearchParams(location.search) → set(…) result: ?archived=1&page=3&sort=price every other control's value survives
The single most common filter bug is one line long: constructing empty parameters instead of copying the current ones.

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;
    }
  });
}
One-way data flow between the URL and the filter panel The URL is the single source of truth. Controls emit patches which are merged and written back to the URL; the panel and the results list both render from the URL, so no component holds a competing copy of the filter state. the URL single source of truth filter panel checkbox states read from the URL results list query built from the same values patch → merge → write No component keeps its own copy, so nothing can disagree with the address bar.
Controls emit patches and render from the URL; because nothing holds a second copy of the state, the panel and the results cannot drift apart.

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.set collapses repeated keys. For multi-value filters you must delete then append in 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.
  • URLSearchParams encodes spaces as +. Round-trips are safe, but a log parser or an API that only understands %20 will 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.
Five raw query values and their parsed meaning Five raw query string values shown with what a correct codec turns each into: presence booleans, an explicit false, an empty string meaning cleared, an absent key meaning the default, and repeated keys read with getAll rather than get. Reading a filter URL back correctly raw value parsed as ?stock=1 "1" true ?stock=0 "0" false — not the string, which is truthy ?stock= "" cleared, distinct from absent no stock key null the default, false ?category=a&category=b ["a","b"] getAll, not get
Every row here is a place a naive reader gets the wrong answer — which is why the codec belongs in one file, not at each call site.

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.