Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * Event name this hook uses to notify every subscriber in the document after it
 * writes the query string.
 *
 * **Why it has to exist**: `history.pushState` / `replaceState` **fire nothing** —
 * `popstate` only arrives when the user really goes back or forward. So after
 * writing the URL this hook must broadcast for itself, or another component on
 * the page subscribed to the same param never sees the update (the easiest thing
 * to miss in a hook like this; the symptom is two filters disagreeing).
 *

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-query-param.json

Prompt

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 writepushState/replaceState change the URL without firing anything; popstate is only for real user navigation. That is why the hook broadcasts its own window event, 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. keepDefault buys 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 undefined and the hook substitutes the default. NaN, Invalid Date and 2026-02-31 are 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=9 is 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.

On This Page