Application
A URL should describe a reproducible view, not a React tree. Shareable state goes in the address bar, personal presentation state goes in local storage, and ephemeral interaction state goes nowhere.
import { Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@luc/ui";
import { decodeParams, encodeParams, enumParam, listParam, params, stringParam } from "@luc/ui/url-state";Change any control and the address bar updates in place: no reload, and no history entry for every keystroke.
/* One adapter, one parameter set. The codecs are the library's; the
plumbing is Next's, through the nuqs bridge. */
const demoParams = params({
range: param(enumParam(["1m", "6m", "1y", "all"] as const), "1y"),
series: param(listParam(stringParam()), ["close", "sma50"]),
q: param(stringParam({ maxLength: 32 }), ""),
});
const [state, setState] = useQueryStates(
{
range: nuqsParser(demoParams.range.codec, demoParams.range.default),
series: nuqsParser(demoParams.series.codec, demoParams.series.default),
q: nuqsParser(demoParams.q.codec, demoParams.q.default),
},
{ history: "replace", shallow: true, scroll: false, clearOnDefault: true },
);
<Select value={state.range} onValueChange={(next) => next && setState({ range: next })}>
<SelectTrigger aria-label={tcs("Range")} className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1m">1 month</SelectItem>
<SelectItem value="6m">6 months</SelectItem>
<SelectItem value="1y">1 year</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
{["close", "sma50", "sma200"].map((id) => (
<Button
key={id}
variant={state.series.includes(id) ? "primary" : "ghost"}
aria-pressed={state.series.includes(id)}
onClick={() => setState({ series: toggle(state.series, id) })}
>
{id}
</Button>
))}
<Input value={state.q} onChange={(event) => setState({ q: event.target.value })} />A codec is the whole vocabulary one parameter needs: how to read it, how to write it, and when two values are the same. Invalid input is not an error — it falls back to the default, because a URL is user input.
Deterministic, and total
The pure API reads and writes a parameter set with no React and no router. Order is declaration order, defaults are omitted, and duplicates collapse.
const decoded = decodeParams("?range=garbage&series=close,close,sma50", params);
// range falls back to "1y"; series is ["close", "sma50"]
const encoded = encodeParams({ range: "1y", series: ["close"] }, params);
// "" — every value was its default, so nothing is writtenThe same functions run on the server, in a test, and in a browser adapter — the parameter set is the contract, not the hook.
Every link below is wrong on purpose. None of them crash, and none render an impossible view: the page falls back to the default and normalizes the URL on the next write.
/* Every href below is deliberately wrong. The decoders are total: an
unreadable value is the default, and the next write normalises the URL. */
{[
"?range=garbage",
"?series=does-not-exist,close,close",
"?q=" + "x".repeat(64),
"?sort=not-a-sort-key-with-spaces",
].map((search) => (
<Link key={search} href={"/application/url-state" + search}>{search}</Link>
))}The rule that keeps a URL readable, and the one future agents get wrong most often.
Shareable
Personal
Ephemeral
Precedence: for shareable state the URL wins; for personal state the local value wins. Nothing is synchronized in both directions.
Read from the published TypeScript API, so a prop that is not listed here does not exist and a default that is listed is the one the compiler sees.
| Export | Kind | Signature |
|---|---|---|
booleanParam | export | () => ParamCodec<boolean> |
currencyParam | export | () => ParamCodec<string> |
dateOnlyParam | export | () => ParamCodec<CalendarDate> |
enumParam | export | <const V extends readonly string[]>(values: V) => ParamCodec<V[number]> |
instantParam | export | () => ParamCodec<number> |
listParam | export | <T>(item: ParamCodec<T>, options?: ListParamOptions) => ParamCodec<T[]> |
nullableParam | export | <T>(codec: ParamCodec<T>) => ParamCodec<T | null> |
numberParam | export | (options?: NumberParamOptions) => ParamCodec<number> |
sortListParam | export | (options?: ListParamOptions) => ParamCodec<SortValue[]> |
sortParam | export | () => ParamCodec<SortValue> |
stringParam | export | (options?: StringParamOptions) => ParamCodec<string> |
structuralEquals | export | (a: unknown, b: unknown) => boolean |
createBrowserUrlStateAdapter | export | (options?: BrowserUrlStateAdapterOptions) => UrlStateAdapter |
decodeParams | export | <M extends ParamSpecMap>(search: string | URLSearchParams, specs: M) => ParamState<M> |
defaultState | export | <M extends ParamSpecMap>(specs: M) => ParamState<M> |
encodeParams | export | <M extends ParamSpecMap>(state: Partial<ParamState<M>>, specs: M) => URLSearchParams |
mergeParams | export | <M extends ParamSpecMap>(search: string | URLSearchParams, state: Partial<ParamState<M>>, specs: M) => URLSearchParams |
param | export | <T>(codec: ParamCodec<T>, fallback: T) => ParamSpec<T> |
params | export | <Specs extends ParamSpecMap>(specs: Specs) => Specs |
patchParams | export | <M extends ParamSpecMap>(search: string | URLSearchParams, specs: M, patch: Partial<ParamState<M>>) => URLSearchParams |
searchToString | export | (search: string | URLSearchParams) => string |
useUrlState | export | <M extends ParamSpecMap>(specs: M, options?: UseUrlStateOptions) => [ParamState<M>, UrlStateSetter<M>] |
Related