useQueryParam
An SSR-safe hook that keeps one piece of component state in a URL query parameter — typed codecs, defaults kept out of the URL, replace by default, and every instance on the page in sync.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-query-param.jsonPrompt
Build a React + TypeScript "useQueryParam" hook (React only — it uses the
History, URL/URLSearchParams and CustomEvent browser APIs; no npm deps).
Contract
- Overloaded signature:
`useQueryParam(key, options?: Partial<UseQueryParamOptions<string>>): [string, SetQueryParam<string>]`
`useQueryParam<T>(key, options: UseQueryParamOptions<T>): [T, SetQueryParam<T>]`
The string overload exists so the common case needs no codec; the generic
overload requires BOTH `defaultValue` and `codec`, so `useQueryParam("page",
{ defaultValue: 1 })` fails to compile instead of silently handing back a
string typed as a number.
- `UseQueryParamOptions<T> = { defaultValue: T; codec: QueryParamCodec<T>;
replace?: boolean /* default true */; keepDefault?: boolean /* default false */ }`.
- `SetQueryParam<T> = (next: T | ((prev: T) => T), options?: { replace?: boolean }) => void`.
The setter's identity depends only on `key`, so it is safe in dependency arrays.
- `QueryParamCodec<T> = { parse: (raw: string) => T | undefined;
serialize: (value: T) => string | null }`.
`parse` returning `undefined` means "this text is not a valid T" — the URL is
user-editable, so that is a normal case, not an exception. `serialize`
returning `null` means "this value does not belong in the URL" and deletes
the parameter. Both are wrapped in try/catch so a throwing codec (JSON.parse)
degrades to the same fallback instead of crashing render.
- Ship codecs: `stringParam`, `numberParam`, `integerParam`, `booleanParam`,
`stringArrayParam(separator = ",")`, `dateParam`, `jsonParam<T>()`.
- number: reject blank (`Number("")` is 0), `NaN` and non-finite.
- integer: reject non-integers on read, truncate on write.
- boolean: write `true`/`false`; read also accepts `1/0`, `yes/no`, `on/off`
and a bare `?flag` (which `URLSearchParams.get` reports as `""`).
- array: `[]` serialises to `""` (so `?tags=` means "explicitly empty" even
when the default is non-empty) and `""` parses back to `[]`.
- date: `YYYY-MM-DD` in LOCAL time both ways — storing UTC makes a user in
UTC+8 share "Jan 15" and the recipient see "Jan 14". After constructing the
Date, read the three fields back: JS silently rolls `2026-02-31` over to
March 3, so an out-of-range day must be rejected, not accepted as a
plausible-looking wrong date.
Behavior
- Built on `useSyncExternalStore`, not `useEffect` + `setState`. The store is
`location.search`.
- `subscribe`: `popstate` (Back/Forward), a custom same-document event this
hook dispatches after its own writes, and — when the Navigation API exists —
`currententrychange` on `window.navigation`, feature-detected and optional.
- `getSnapshot`: `new URLSearchParams(location.search).get(key)`, then decode.
- `getServerSnapshot`: the default value. Server and first client paint
therefore agree (no hydration mismatch); the real URL value takes over one
render after hydration. Nothing reads `location` during render.
- **The hard part: `pushState`/`replaceState` fire NO event at all** — `popstate`
only fires for real user navigation. So after every write the hook dispatches
its own event on `window` and every instance re-reads. Without it, two
components reading the same param drift apart silently. Dispatch on
**`window`, not a module-level subscriber set**: this file gets copied into
consumer projects, so one page can hold two independent copies; `window` plus
a constant event name is the only thing both share.
(Measured: on Chromium the Navigation API listener alone happens to keep
instances in sync, which hides a missing broadcast — with `window.navigation`
removed, the same test drops to "the writer itself does not see its own
write". Do not skip the broadcast because it looks redundant in Chrome.)
- **Same-tick writes must compose, not overwrite.** Every write re-reads
`location.href` at call time and mutates a fresh `URLSearchParams`, so
`setA(1); setB(2)` in one handler ends with BOTH params present; a setter
that captured the query string at render time would let the second write
erase the first. Functional updates read the current URL too, so
`setPage(p => p + 1)` twice in one handler really adds 2.
- **One event, one history entry.** Track "already wrote in this tick" on
`window` (cleared in a `queueMicrotask`); the first write honours the
requested policy and every later write in the same synchronous block is
forced to `replaceState`. Otherwise changing three filters in one handler
pushes three entries and one Back press restores a third of the UI.
- **`replace` defaults to true.** Filter/search/paginate are high-frequency;
one history entry per keystroke means the user needs 50 Back presses to leave
the page. Pass `{ replace: false }` for low-frequency state (tabs, opened
detail) where Back *should* step through.
- **Skip no-op writes** by comparing the normalised `searchParams.toString()`
before and after the mutation — not `url.href`, because `URLSearchParams`
rewrites spaces to `+` and commas to `%2C`, and an href comparison would call
that a change and push an entry for a write that changed nothing.
- **Defaults never enter the URL.** Before writing, compare the serialised next
value with the serialised default; if equal, delete the parameter instead.
A shared link then carries only what the user actually changed, not
`?page=1&sort=default&view=grid`. `keepDefault: true` opts out.
- **Invalid input falls back.** `?page=abc`, `?page=2.5`, `?due=2026-02-31` all
resolve to `defaultValue`; `NaN` and `Invalid Date` never reach the consumer.
- Pass `null` as the history state argument. Next.js App Router patches
`pushState`/`replaceState`: a state object carrying its internal marker is
treated as an internal call and skips the router's canonical-URL sync (its
next `replaceState` would then wipe your params); `null` takes the "external
write" branch, where Next copies its internals back in and syncs the URL.
- **Cache the parsed snapshot** in a module-level map keyed by
`key + serialized default`, storing `{ raw, value }`. `useSyncExternalStore`
requires `getSnapshot` to return an identical reference while the store is
unchanged; array/Date/JSON codecs allocate a new object per parse, so without
the cache React re-renders forever, and the returned value gets a fresh
identity every frame (any consumer `useEffect([value])` then re-runs every
render). Consequence to document: **one param name, one codec** — the cache
does not key on the codec, because factory codecs such as
`stringArrayParam()` written inline are a new object every render and would
never hit the cache.
- **Never write the URL from an effect.** The hook only writes when the
consumer calls the setter. A mount-time or effect-time write fights the
user's navigation: press Back to page 4 and an effect immediately shoves the
URL to page 1 again. Out-of-range values are clamped *while reading*
(`Math.min(page, pageCount)`), leaving the URL alone.
- The lint rules shipped with React 19 (`react-hooks/refs`) reject reading or
writing a ref during render, so the "latest options" ref is written in an
effect and only read inside the setter (an event-time read); the snapshot
memo lives in a module-level cache instead.
- Honest limits: repeated keys (`?tag=a&tag=b`) are not modelled — reads take
the first, writes collapse to one, use `stringArrayParam()` for lists; code
that calls `history.pushState` directly, bypassing this hook, produces no
browser event at all (the Navigation API listener covers Chromium, elsewhere
it is noticed at the next `popstate`); and every write re-serialises the whole
query string, so spaces in unrelated params get normalised to `+`.
Rendering & styling
- The hook renders nothing — it returns `[value, setValue]`. Consumers own the
UI; style anything driven by a param with semantic tokens (`bg-muted`,
`bg-background`, `bg-primary/10`, `text-foreground`, `text-muted-foreground`,
`focus-visible:ring-ring`) and keep chips/toggles keyboard reachable with
`aria-pressed`. A filter surface must also honour the honesty rules: the
result count has to be real, and the "filtered down to zero" empty state
needs different copy from "there is nothing here at all" plus a visible
"Clear filters" way out.
Customization levers
- Write policy — `replace: true` is the default; flip it per hook or per call
(`setValue(next, { replace: false })`). A third policy, "push on the first
change of a burst and replace afterwards", is one flag away from the existing
tick-batching helper.
- Codecs — the seven built-ins are the floor, not a menu. Compose them
(`{ parse: dateParam.parse, serialize: v => v === null ? null : dateParam.serialize(v) }`
gives you a nullable date), swap `stringArrayParam("-")` for prettier URLs, or
validate against a zod schema inside `parse` and return `undefined` when it
fails so unknown enum values fall back instead of poisoning the UI.
- URL shape — the codec decides how the value looks; keep short keys and short
values if the links get pasted into chat. Add a key prefix (`f_status`) if the
page shares the query string with a router or analytics tags.
- Default policy — `keepDefault: true` per param when a URL must always be
fully explicit (bookmarkable reports, links pasted into runbooks).
- Debounce — for a text filter that triggers a request, wrap the value with a
debounce hook for the *fetch* and keep writing the URL on every keystroke;
because writes are `replaceState`, they cost nothing in history.
- Clamping and derived state — clamp out-of-range values at read time and derive
page counts in render; do not "repair" the URL from an effect.
- Batching — several params changed in one handler already collapse into one
history entry; call the setters in whatever order reads best.Concepts
- Silent write —
pushState/replaceStatechange the URL without firing anything;popstateis only for real user navigation. That is why the hook broadcasts its ownwindowevent, and why a second component reading the same param would otherwise go stale forever. On Chromium the Navigation API accidentally papers over a missing broadcast, so "it works in Chrome" is not evidence. - Same-tick composition — each write re-reads the live URL instead of a query string captured at render, so two setters fired from one handler stack up (
?a=1&b=2) rather than the second erasing the first; a "already wrote this tick" flag then collapses the burst into a single history entry. - Replace by default — a URL change and a history entry are separate decisions. High-frequency state (typing, dragging, paging) should change the URL without leaving a trail; low-frequency state (tab, opened detail) earns a Back step.
- Defaults are absence — "equal to the default" is encoded as "not present", so a shared link carries only the user's actual choices and an unset param and a param set back to its default are indistinguishable.
keepDefaultbuys explicitness back when a link has to be self-describing. - Codec, not cast — the URL is a string the user can hand-edit, so parsing is a total function: valid text becomes a value, anything else becomes
undefinedand the hook substitutes the default.NaN,Invalid Dateand2026-02-31are rejected at the boundary instead of travelling downstream. - Snapshot identity — the store is the URL, but the value handed to React must keep the same reference while the raw text is unchanged; a per-key parse cache is what stops array and date codecs from re-rendering forever and from re-firing every consumer effect that lists the value as a dependency.
- Read-time clamping — an out-of-range
?page=9is corrected while rendering, never by writing back from an effect; a URL-repairing effect races the user's Back button and undoes the navigation they just made.
useEventSource
Subscribe to a Server-Sent Events stream with a four-state connection machine, capped exponential-backoff reconnects, named-event subscriptions and callbacks that never re-open the stream.
useIndexedDb
An async IndexedDB-backed store hook with loading state, versioned upgrades, blocked-deadlock handling, quota errors, key-range queries and cross-tab sync.