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.
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 |
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.
Gotchas & Failure Modes
URLSearchParamsencodes spaces as+, not%20. It decodes both, so round-trips are safe, but a server or log parser that only understands%20will see the wrong value.params.setremoves every existing value for the key. For genuinely repeated keys you mustdeletethenappendin a loop, or you will silently collapse a multi-value parameter to one.- Empty string and absent are different.
?q=gives""while noqgivesnull. 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-appendinglocation.hashdrops the user’s in-page anchor on every filter change. popstatedoes not fire forpushStateorreplaceState. 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.
Related
- Syncing Filters to the URL with URLSearchParams — a complete filter panel wired to the query string, including the merge and guard steps.
- Debouncing URL Updates While Typing — keeping a search box responsive without writing a history entry per keystroke.
- Encoding Arrays and Objects in Query Strings — repeated keys, delimiters, and when a compact encoding is worth the opacity.
- pushState & replaceState Usage — the write primitives underneath every query-string update.
- Deep Linking Implementation — making the URLs this layer produces reconstruct the view on a cold load.