modern-react-spa

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 nuqs or 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 URLSearchParams for 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

  1. Raw URLSearchParams for non-trivial state — no types, no validation.
  2. Forgetting history: 'replace' for high-frequency inputs — back button is unusable.
  3. URL keys that collide with framework-reserved names (from, to, redirect) — surprising bugs.
  4. Not batching multi-filter updates — N history entries per user interaction.
  5. URL state for values that shouldn’t be shareable (auth tokens, personal info).
  6. Cross-tab sync assumed from URL state — it’s not; add BroadcastChannel.
  7. 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.
  • nuqs for any router; TanStack Router’s validateSearch if 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

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.

Topics in this chapter (3)
  1. 15.1 nuqs and TanStack Router search params
  2. 15.2 Shareable filter UIs — the “kanban URL” pattern
  3. 15.3 Persisting state across tabs