Chapter 15
URL as State
URL as state in React — typed search params with nuqs or TanStack Router, shareable filter UIs that survive reload, and multi-tab persistence.
Published 2026-05-23
🎯 Chapter Goal — After this chapter you can recognise state that belongs in the URL, model it with a typed schema using
nuqsor TanStack Router search params, build shareable filter UIs that survive reload, and avoid the gotchas (history pollution, schema drift, cross-tab sync).🧭 Prerequisites — Ch 5 (routing) — TanStack Router examples reference its
validateSearch. Ch 12 (which kind of state).
🔹 15.1 nuqs and TanStack Router search params
Two well-supported patterns in 2026.
nuqs — for any router
https://nuqs.47ng.com/ — works with React Router v7 and any router via adapters. Typed, schema-validated, batched updates.
import { useQueryState, parseAsInteger, parseAsStringEnum } from 'nuqs';
const Status = ['all', 'paid', 'pending', 'overdue'] as const;
type Status = typeof Status[number];
const InvoicesPage = () => {
const [status, setStatus] = useQueryState(
'status',
parseAsStringEnum<Status>([...Status]).withDefault('all'),
);
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
// URL is /invoices?status=paid&page=3
// status: Status (typed); page: number (typed)
};
nuqs parses and serialises; you read and write typed values; the URL stays in sync. The withDefault(…) keeps the URL clean — the default value is omitted from the query string.
TanStack Router — first-class search params
Already shown in Ch 5 §5.2. The pattern:
const invoicesRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/invoices',
validateSearch: z.object({
status: z.enum(['all', 'paid', 'pending', 'overdue']).default('all'),
page: z.number().int().min(1).default(1),
}),
component: InvoicesPage,
});
// In InvoicesPage:
const { status, page } = invoicesRoute.useSearch();
const navigate = useNavigate({ from: invoicesRoute.fullPath });
// Update:
navigate({ search: (prev) => ({ ...prev, status: 'paid' }) });
The Zod schema validates on every navigation. A bookmarked URL with page=banana fails validation loudly rather than silently rendering wrong data.
Picking one
- On TanStack Router → use its built-in search-param API.
- On React Router v7 (or anything else) → use
nuqs. - Either way, never use raw
URLSearchParamsfor non-trivial state. The typing and validation discipline disappears.
🔹 15.2 Shareable filter UIs — the “kanban URL” pattern
The signature use case: a dashboard or kanban with multiple filters, where users can share the exact view by sending a URL.
/invoices?status=overdue&assignee=shreya&dateRange=last-30d&groupBy=tenant
When a user adjusts a filter:
const [filters, setFilters] = useQueryStates({
status: parseAsStringEnum<Status>([...Status]).withDefault('all'),
assignee: parseAsString,
dateRange: parseAsStringEnum<DateRange>([...DateRanges]).withDefault('last-7d'),
groupBy: parseAsStringEnum<GroupBy>([...GroupBys]).withDefault('tenant'),
});
<FilterBar
filters={filters}
onChange={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
useQueryStates batches multiple URL params into one navigation — important to avoid five separate history entries when the user changes five filters.
Pair with TanStack Query — the filter object is the query key:
useQuery({
queryKey: ['invoices', filters],
queryFn: () => fetchInvoices(filters),
});
URL changes → query key changes → query refetches with new filters. Three patterns wired with no extra glue.
History pollution
By default, every URL change creates a history entry. If the user types in a search box, every keystroke adds a back-button entry. Almost always wrong.
const [q, setQ] = useQueryState('q', parseAsString.withOptions({ history: 'replace' }));
history: 'replace' rewrites the current URL instead of pushing a new entry. Use for high-frequency updates (search inputs, filter sliders); use the default 'push' for state changes the user expects to back-button through.
🔹 15.3 Persisting state across tabs
When the user has the same SPA open in two tabs and changes a setting in one, the other should reflect it.
The mechanism — BroadcastChannel
const ch = new BroadcastChannel('acme-app');
ch.addEventListener('message', (e) => {
if (e.data.type === 'filters-changed') {
qc.invalidateQueries({ queryKey: ['invoices'] }); // refetch with new filters
}
});
// On filter change:
const onFilterChange = (patch) => {
setFilters((prev) => ({ ...prev, ...patch }));
ch.postMessage({ type: 'filters-changed' });
};
The other tab receives the message; its useQuery refetches; the UI updates without the user touching it.
⚠️ Cross-tab sync is not what URL state buys you natively. URL state survives reload (in the same tab); it doesn’t propagate to other tabs. BroadcastChannel (or localStorage events) is the additional layer.
For auth state specifically, this matters: log out in one tab should log out in all tabs. Wire it deliberately.
🪤 Common Pitfalls
- Raw
URLSearchParamsfor non-trivial state — no types, no validation. - Forgetting
history: 'replace'for high-frequency inputs — back button is unusable. - URL keys that collide with framework-reserved names (
from,to,redirect) — surprising bugs. - Not batching multi-filter updates — N history entries per user interaction.
- URL state for values that shouldn’t be shareable (auth tokens, personal info).
- Cross-tab sync assumed from URL state — it’s not; add
BroadcastChannel. - Schema drift — renaming a URL key breaks all old bookmarks; treat URL keys as a public API.
✅ Recap
- URL is the right store for state that should be bookmarkable / shareable / survive reload.
nuqsfor any router; TanStack Router’svalidateSearchif you’re already there.- Filter object becomes the TanStack Query key — three patterns wired with no glue.
history: 'replace'for high-frequency updates; default'push'for user-meaningful changes.- Cross-tab sync is a separate layer (
BroadcastChannel) — URL alone doesn’t propagate.
🔗 Further Reading
- https://nuqs.47ng.com/ — nuqs docs.
- https://tanstack.com/router/latest — TanStack Router search-param reference.
- “Cool URIs don’t change” — Tim Berners-Lee, on URL stability as a public contract.
- Ch 5 §5.2 (TanStack Router); Ch 14 (TanStack Query pairing).
In the book — not on the site
Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.