@pyreon/url-state — API Reference
Generated from
url-state'ssrc/manifest.ts— the same source that powersllms.txtand MCPget_api. Do not edit this page by hand; edit the manifest. For the conceptual guide, see url-state.
Reactive URL search-param state for Pyreon. Each search parameter is a signal synced with the browser URL. Supports single-param mode (useUrlState("page", 1)) and schema mode (useUrlState({ page: 1, sort: "name" })). Auto-coerces types (numbers, booleans, arrays), uses replaceState to avoid history spam, supports configurable debounce for high-frequency updates, and is SSR-safe (signals initialize to the default value on the server — it does NOT read the request URL; reads window.location on the client).
Features
useUrlState(key, default) — single-param signal synced to URL
useUrlState(schema) — multi-param schema mode
Auto type coercion for numbers, booleans, arrays
replaceState by default (no history spam)
Configurable debounce for high-frequency updates
Cross-hook sync — two signals bound to the same param stay in sync
batchUrlUpdates() — coalesce a multi-param update into ONE history entry
clearOnDefault: false — keep a param in the URL at its default value
SSR-safe — initializes to the default on the server (reads the URL on the client)
setUrlRouter() for @pyreon/router integration (replace() by default, push() for { replace: false })
Complete example
A full, end-to-end usage of the package:
import { useUrlState, setUrlRouter } from '@pyreon/url-state'
import { signal } from '@pyreon/reactivity'
// Single parameter — type inferred from default value
const page = useUrlState('page', 1)
page() // 1 (number, auto-coerced from ?page=1)
page.set(2) // URL → ?page=2 via replaceState
page.reset() // removes ?page, signal returns default (1)
page.remove() // removes ?page entirely
// Schema mode — multiple params from a single call
const filters = useUrlState({ q: '', sort: 'name', desc: false })
filters.q.set('hello') // ?q=hello&sort=name&desc=false
filters.sort.set('date') // ?q=hello&sort=date&desc=false
filters.desc.set(true) // ?q=hello&sort=date&desc=true
// Array parameters with repeated keys
const tags = useUrlState('tags', [] as string[], { arrayFormat: 'repeat' })
tags.set(['typescript', 'pyreon']) // ?tags=typescript&tags=pyreon
// Debounce for high-frequency updates (e.g. search input)
const search = useUrlState('q', '', { debounce: 300 })
// typing "hello" fires one URL update after 300ms pause, not 5
// Batch — collapse a multi-param update into ONE history entry
import { batchUrlUpdates } from '@pyreon/url-state'
batchUrlUpdates(() => {
filters.q.set('hello')
filters.sort.set('date')
}) // one replaceState, not two
// Cross-hook sync — two signals bound to the same key stay in sync
const a = useUrlState('page', 1)
const b = useUrlState('page', 1)
a.set(5) // b() is now 5 too, and b's onChange fires
// Router integration — router.replace() by default, router.push() for { replace: false }
import { useRouter } from '@pyreon/router'
const router = useRouter()
setUrlRouter(router) // replace() by default; push() honours { replace: false }
// SSR-safe — initializes to the default on the server, reads window.location on the client
// No typeof window checks needed in your componentsExports
| Symbol | Kind | Summary |
|---|---|---|
useUrlState | hook | Create a reactive signal synced to a URL search parameter. |
setUrlRouter | function | Configure useUrlState to use a @pyreon/router instance for URL updates instead of the raw history API. |
batchUrlUpdates | function | Collapse several useUrlState writes into ONE history entry. |
API
useUrlState hook
<T>(key: string, defaultValue: T, options?: UrlStateOptions) => UrlStateSignal<T>Create a reactive signal synced to a URL search parameter. Type is inferred from the default value — numbers, booleans, strings, and arrays are auto-coerced. Uses replaceState by default (no history entries). Returns a UrlStateSignal<T> with .set(), .reset(), and .remove(). Schema mode overload: useUrlState({ page: 1, sort: "name" }) creates multiple synced signals from a single call. SSR-safe — initializes to the default value on the server (does NOT read the request URL).
Example
// Single param:
const page = useUrlState('page', 1)
page() // 1
page.set(2) // URL → ?page=2
// Schema mode:
const { q, sort } = useUrlState({ q: '', sort: 'name' })
q.set('hello') // ?q=hello&sort=name
// Array with repeated keys:
const tags = useUrlState('tags', [] as string[], { arrayFormat: 'repeat' })
tags.set(['a', 'b']) // ?tags=a&tags=bCommon mistakes
Using pushState behavior (adds history entries per keystroke) — useUrlState defaults to replaceState; if you pass
{ replace: false }on a high-frequency input, the browser back button breaksExpecting a malformed value in the URL to surface as an error — it does not, by design: a URL is untrusted input (hand-edited, truncated by a chat client, shared from an older build), so a value the serializer cannot read falls back to the default and dev-warns naming the param, rather than throwing out of component setup.
Forgetting the default value — the type is inferred from it and determines the auto-coercion strategy (number default = coerce to number, boolean default = coerce to boolean)
Reading useUrlState in a non-reactive scope at component setup — the signal reads the URL once; wrap in a reactive scope to track URL changes
Calling setUrlRouter before the router is available — SSR renders may not have a router instance yet
Assuming a hand-rolled router object with only
replacehonours{ replace: false }— it cannot, so the update is downgraded to a replace and Back will not undo it. Give it apush(path); a dev warning fires once if you do not.
See also: setUrlRouter
setUrlRouter function
(router: UrlRouter) => voidConfigure useUrlState to use a @pyreon/router instance for URL updates instead of the raw history API. When set, URL changes go through the router's navigation system, ensuring route guards, middleware, and scroll management integrate correctly. The router needs replace(path); push(path) is optional but is what makes { replace: false } mean anything — without it a push-intent update falls back to replace and dev-warns once, so Back will not undo it. @pyreon/router has both.
Example
import { useRouter } from '@pyreon/router'
import { setUrlRouter } from '@pyreon/url-state'
const router = useRouter()
setUrlRouter(router)
// Now useUrlState routes through the router: replace() by default,
// push() for a { replace: false } update (so Back undoes it)See also: useUrlState
batchUrlUpdates function
<T>(fn: () => T) => TCollapse several useUrlState writes into ONE history entry. Every .set() / .reset() / .remove() invoked inside fn is coalesced into a single history.replaceState / pushState (or one router.replace). Signal values still update synchronously — only the URL write is deferred to the end of the batch. Signal notifications are also batched, so subscribers reading several params re-run once, and debounce is bypassed. Critical with replace: false: without batching, an N-param update pushes N history entries, so the back button steps through each intermediate state. If any write requested replace: false, the single batched write uses pushState; otherwise replaceState.
Example
import { useUrlState, batchUrlUpdates } from '@pyreon/url-state'
const { page, q, sort } = useUrlState({ page: 1, q: '', sort: 'name' })
// One history entry for the whole "apply filters" action:
batchUrlUpdates(() => {
page.set(1)
q.set('hello')
sort.set('date')
}) // → ?q=hello&sort=date (one replaceState)See also: useUrlState
Package-level notes
Note: Type coercion is based on the default value:
useUrlState("page", 1)coerces?page=2to number2.useUrlState("page", "1")keeps it as string"1". Always provide the right type as default.
History: Uses
replaceStateby default — no history entries per update. This prevents the back button from stepping through every intermediate value during typing.
SSR: SSR-safe out of the box. On the SERVER it does NOT read the request URL — every signal initializes to its default value (no popstate listener, no history calls). On the CLIENT it reads from
window.location.search. No environment checks needed in component code. If a param must be present in the server-rendered HTML (e.g. SEO of a filtered list), seed the render from your route/loader layer instead.
Debounce: For high-frequency updates (search inputs, sliders), pass
{ debounce: 300 }to coalesce URL writes. Without debounce, every keystroke triggers a replaceState call. The signal itself updates synchronously — only the URL write is delayed.
Cross-hook sync: Two
useUrlState("page", 1)calls in different components are independent signals bound to the same param — and they stay in sync. When one writes, the other re-reads the URL and updates (firing itsonChange). No store lifting required.
Batch: Wrap several
.set()calls inbatchUrlUpdates(() => { … })to collapse a multi-param update into ONE history entry — critical withreplace: false, where N un-batched writes would push N back-stack entries. Signal values update synchronously inside the batch; debounce is bypassed.