Query String State Sync

Path segments describe what the user is looking at; query parameters describe how. Sorting, filtering, pagination, search terms, an open tab, a selected date range — all of it is state a user would reasonably expect to bookmark, share, and reach again with the back button, and all of it belongs in the query string. This guide covers the mechanics of keeping component state and the query string in bidirectional sync without producing an unusable history stack or a URL that silently drops half its values.

← Back to History API & State Management

The Problem

The naive implementation — read a parameter where you need it, write one where you change it — looks correct in a demo and fails in four specific ways as soon as a second control is added to the page. Each failure is easy to fix in isolation and nearly impossible to fix retroactively once a dozen components have grown their own reads and writes, which is the argument for building the sync layer before the second filter rather than after the tenth.

History flooding. A filter panel that calls pushState whenever any control changes turns a session into a history stack the user cannot escape. Adjusting a price slider through twenty positions creates twenty entries; pressing Back now walks the slider backwards one notch at a time instead of returning to the previous page. Users experience this as “the back button is broken”, and the fix is a policy decision about which changes are navigations and which are refinements — the distinction explored in pushState vs replaceState.

Round-trip loss. Everything in a query string is a string. A component holding { page: 2, tags: ["a", "b"], includeArchived: false } and writing it naively gets back { page: "2", tags: "a,b", includeArchived: "false" } — and "false" is truthy. Numbers become strings, booleans become truthy strings, arrays become whatever the joining character was, and dates become whatever toString() produced in the user’s locale.

Feedback loops. The URL updates state and state updates the URL. Without a guard, writing to the URL triggers the listener that reads the URL, which writes state, which writes the URL again. In development this shows up as a render loop; in production it shows up as a browser tab pegging a CPU core.

Parameter clobbering. Two independent components each own part of the query string. The filter panel writes ?category=laptops, then the sort control writes ?sort=price by building a fresh URLSearchParams — and the category is gone. Every writer must merge into the existing parameters rather than replace them.

Four ways naive query-string synchronisation fails Four labelled panels: history flooding from pushing an entry per keystroke, type loss where numbers and booleans return as strings, a feedback loop between URL and state, and parameter clobbering where one writer overwrites another writer's keys. 1 · History flooding …20 more One entry per slider position. Back walks the slider, not the page. 2 · Round-trip type loss out: page 2 · archived false · tags [a, b] back: "2" · "false" (truthy!) · "a,b" Every value returns as a string. 3 · Feedback loop state URL No guard means each write re-triggers the other. 4 · Parameter clobbering ?category=laptops ?sort=price The sort writer rebuilt the params from scratch — category is silently gone.
The four failure modes a query-string sync layer exists to prevent: flooded history, lost types, feedback loops, and one writer erasing another's keys.

Core API & Primitives

URLSearchParams. The standard parser and serialiser. It handles percent-encoding correctly in both directions, supports repeated keys through getAll, and iterates in insertion order. Constructing one from location.search gives you a mutable copy; the original URL is untouched until you write it back.

// TypeScript 5.x — the surface worth knowing

const params = new URLSearchParams(location.search);

params.get("sort");        // string | null — first value only
params.getAll("tag");      // string[] — every value for a repeated key
params.set("page", "2");   // replaces all existing values for the key
params.append("tag", "b"); // adds another value, keeping existing ones
params.delete("page");     // removes the key entirely
params.has("archived");    // presence check, independent of value
params.sort();             // stable key order — important for cache keys
params.toString();         // "page=2&tag=a&tag=b", already encoded

history.replaceState and history.pushState. The two writes, distinguished only by whether a new session-history entry is created. For query-string sync the default should be replaceState; pushState is the exception, reserved for changes the user would expect Back to undo.

popstate. Fires when the user moves through history. This is the read side of the loop, and the only reliable signal that the URL changed for a reason your code did not initiate. Its cross-browser quirks are covered in popstate event handling.

A codec per parameter. The piece that is not built in. Each parameter needs a pair of functions — one that turns a typed value into a string, one that turns a string back into the typed value with a fallback for garbage. This is what prevents round-trip loss, and it is worth writing once rather than per component. Encoding arrays and objects in query strings goes deeper on the non-scalar cases.

Step-by-Step Implementation

Prerequisite: a router that owns URL writes. Two independent systems writing the query string will race, so the sync layer below should be the only code calling pushState or replaceState for parameters.

1. Define a typed schema with codecs

Declare, per parameter, how to read it, how to write it, and what its default is. A parameter equal to its default is omitted from the URL entirely, which keeps shared links short and makes two equivalent states produce one canonical URL.

// TypeScript 5.x — no dependencies

interface Codec<T> {
  parse(raw: string | null): T;
  serialise(value: T): string | null; // null means "omit from the URL"
}

const numberCodec = (fallback: number): Codec<number> => ({
  parse: (raw) => {
    const n = raw === null ? NaN : Number(raw);
    return Number.isFinite(n) ? n : fallback;
  },
  serialise: (v) => (v === fallback ? null : String(v)),
});

const boolCodec = (fallback: boolean): Codec<boolean> => ({
  // Presence-style booleans: "?archived" and "?archived=1" both mean true.
  parse: (raw) => (raw === null ? fallback : raw !== "0" && raw !== "false"),
  serialise: (v) => (v === fallback ? null : v ? "1" : "0"),
});

const listCodec = (fallback: string[] = []): Codec<string[]> => ({
  parse: (raw) => (raw ? raw.split(",").filter(Boolean) : fallback),
  serialise: (v) => (v.length === 0 ? null : v.join(",")),
});

const schema = {
  page: numberCodec(1),
  sort: { parse: (r: string | null) => r ?? "relevance", serialise: (v: string) => (v === "relevance" ? null : v) },
  tags: listCodec(),
  archived: boolCodec(false),
} as const;

2. Read the whole state from the URL in one pass

Reading parameter-by-parameter at call sites is how defaults drift. Produce the entire typed object from location.search in a single function, and let components consume that.

// TypeScript 5.x

type Schema = typeof schema;
type State = { [K in keyof Schema]: ReturnType<Schema[K]["parse"]> };

function readState(search: string = location.search): State {
  const params = new URLSearchParams(search);
  const out = {} as Record<string, unknown>;
  for (const [key, codec] of Object.entries(schema)) {
    out[key] = codec.parse(params.get(key));
  }
  return out as State;
}

3. Write by merging, never by replacing

Start from the current parameters so keys owned by other features survive. Delete a key when its codec returns null, and sort before serialising so the same logical state always produces byte-identical URLs — which matters for caching, analytics grouping, and deduplicating history entries.

// TypeScript 5.x

function writeState(patch: Partial<State>, mode: "push" | "replace" = "replace"): void {
  const params = new URLSearchParams(location.search); // merge, do not rebuild

  for (const [key, value] of Object.entries(patch)) {
    const codec = schema[key as keyof Schema];
    if (!codec) continue;
    const encoded = codec.serialise(value as never);
    if (encoded === null) params.delete(key);
    else params.set(key, encoded);
  }

  params.sort(); // canonical ordering
  const query = params.toString();
  const url = `${location.pathname}${query ? `?${query}` : ""}${location.hash}`;

  if (url === location.pathname + location.search + location.hash) return; // no-op
  if (mode === "push") history.pushState(history.state, "", url);
  else history.replaceState(history.state, "", url);
}

The early return on an unchanged URL is what stops the feedback loop at its source: a write that would not change anything performs no history operation, so no listener fires and no second write is provoked.

4. Subscribe to external URL changes

Only popstate matters here, because pushState and replaceState fire nothing. Recompute the state and hand it to the application; the guard from step 3 means a re-render that writes the same values back is inert.

// TypeScript 5.x

export function subscribeToQueryState(onChange: (state: State) => void): () => void {
  let last = location.search;

  const handler = () => {
    if (location.search === last) return; // path-only change; nothing to do
    last = location.search;
    onChange(readState());
  };

  window.addEventListener("popstate", handler);
  return () => window.removeEventListener("popstate", handler);
}

5. Choose push or replace per interaction

The policy, not the mechanism, is what users feel. A useful default: replace for refinements of the current view, push for changes a user would describe as “going somewhere”.

Interaction Mode Reasoning
Typing in a search box replace, debounced Every keystroke is a refinement, not a destination
Committing a search (Enter) push The user expects Back to return to the previous results
Toggling a filter checkbox replace One of many refinements to the same result set
Changing page number push Pagination reads as movement; Back should go to page 1
Dragging a range slider replace, on release Intermediate positions are not destinations
Switching a tab within a view push Users routinely use Back to leave a tab
Changing sort order replace The same items, differently arranged
The guarded synchronisation loop between state and the query string A cycle diagram. A user interaction patches state, the writer merges into existing parameters and compares the resulting URL with the current one, skipping the history write when they are identical. A popstate event reads the URL back into state. The equality guard breaks the loop. Interaction patch: { page: 3 } Merge + serialise start from current params drop defaults, then sort Equality guard same URL? yes → do nothing History write push or replace popstate → readState() → back into the application The guard is the whole trick A write that changes nothing performs no history operation, so nothing fires back.
State and URL stay in sync through one guarded path: merge, canonicalise, compare, and write only on a genuine difference.

Deciding What Belongs in the Query String

Not everything the application knows should end up in the address bar, and the boundary is worth drawing explicitly before writing any codecs. Three storage locations compete, and each has a property the others lack.

The path carries identity. It answers “what is this page?” and it is what a crawler indexes, what a canonical tag points at, and what a person reads before deciding whether to click a shared link. Anything that changes the answer to “what am I looking at” belongs there — a product, an article, a user profile. Putting a filter in the path fragments one page into thousands of near-duplicate URLs and dilutes exactly the crawlability the path exists to provide.

The query string carries a view of that identity. It answers “how am I looking at it?” and it is visible, shareable, and durable across sessions. Its cost is that it is public: everything in it appears in server logs, in referrer headers sent to third parties, and in any analytics tool watching page views. A draft message, a partially entered form, or anything derived from personal data does not belong there regardless of how convenient it would be.

The history state object carries data that must survive back and forward navigation but should not be visible or shareable. It is invisible to crawlers and to the user, survives reloads within the session, and holds structured-cloneable values rather than strings — but it is lost the moment the URL is copied into another browser, so nothing essential to reconstructing the view can live there alone.

A short test resolves most cases: if a colleague pasted this URL into a chat, what would they need to see the same thing you see? Everything in that answer goes in the path or the query string. Everything else — scroll offsets, cached list positions, an expanded row, the fact that the user arrived from a search — goes in the history state object, where it restores on Back without leaking into shared links.

Verification & Testing

The behaviours worth asserting are round-trip fidelity, history discipline, and back-button correctness.

// @playwright/test v1.44
import { test, expect } from "@playwright/test";

test("filters round-trip through the URL and respect history policy", async ({ page }) => {
  await page.goto("/products");

  await page.getByLabel("Include archived").check();
  await page.getByRole("combobox", { name: "Sort" }).selectOption("price");

  // Canonical, sorted, defaults omitted.
  await expect(page).toHaveURL(/\?archived=1&sort=price$/);

  // Two refinements must not have added two history entries.
  const depth = await page.evaluate(() => history.length);
  await page.getByLabel("Include archived").uncheck();
  expect(await page.evaluate(() => history.length)).toBe(depth);

  // Pagination is a real navigation, so Back returns to page 1.
  await page.getByRole("link", { name: "Next page" }).click();
  await expect(page).toHaveURL(/page=2/);
  await page.goBack();
  await expect(page).not.toHaveURL(/page=/);
  await expect(page.getByRole("combobox", { name: "Sort" })).toHaveValue("price");
});

Unit-test each codec against hostile input directly — parse(""), parse(null), parse("NaN"), parse("-0"), parse("999999999999999999999") — because every one of those reaches production eventually through a hand-edited or truncated shared link. The rule to assert is that parsing never throws and always returns a value of the declared type.

For a fast manual check, open DevTools, run history.length, interact with every control on the page, and run it again. If the number climbed by more than the number of genuine navigations you performed, the push/replace policy is wrong somewhere.

Performance Tuning

Debounce writes, not state. The input should update immediately so typing feels responsive; only the replaceState call needs debouncing, typically at 200–300 ms. Debouncing the state itself makes the field feel laggy while gaining nothing.

Batch multi-parameter updates into one write. Applying five filters at once should produce one history operation, not five. Accept a whole patch object rather than exposing a single-key setter.

Sort parameters before serialising. Beyond canonical URLs, this makes the query string a stable cache key, so an HTTP cache, a client-side data cache, and analytics all group the same logical state together instead of fragmenting it across permutations.

Keep the query string small. URLs are not a serialisation format for arbitrary objects; practical ceilings around 2,000 characters exist in intermediaries even where browsers allow more. State that is large but not shareable belongs in the history entry’s state object instead — subject to the state size limits across browsers.

Avoid re-parsing on every render. readState() allocates a URLSearchParams and walks the schema. Call it once per URL change and cache the result, keyed on location.search.

The five steps of a query string write A five-step pipeline for writing query state: merge into the current parameters, drop values equal to their default, sort the keys, compare with the current URL and skip if unchanged, then perform the history write. The write path, in order 1 · merge start from location.search other features' keys survive 2 · drop defaults a default means absent two states, one URL 3 · sort canonical key order stable cache keys 4 · compare same URL? do nothing this breaks the loop 5 · write push or replace policy, not mechanism
Steps two and three make the URL canonical; step four is what stops state and URL from triggering each other forever.

Gotchas & Failure Modes

  • URLSearchParams encodes spaces as +, not %20. It decodes both, so round-trips are safe, but a server or log parser that only understands %20 will see the wrong value.
  • params.set removes every existing value for the key. For genuinely repeated keys you must delete then append in a loop, or you will silently collapse a multi-value parameter to one.
  • Empty string and absent are different. ?q= gives "" while no q gives null. Codecs must decide which one means “cleared” and treat the other consistently.
  • The fragment is not part of location.search. Rebuilding a URL without re-appending location.hash drops the user’s in-page anchor on every filter change.
  • popstate does not fire for pushState or replaceState. Your own writes must update application state directly; do not wait for an event that will never arrive.
  • Ordering the parameters changes the URL string but not the state, which is exactly why sorting matters — without it, two identical views produce two different shareable links and two different cache entries.
  • Very old shared links will contain parameters you have since removed. Ignore unknown keys silently rather than throwing, and never let an unrecognised parameter block rendering.