pyreon

@pyreon/url-state turns URL search parameters into reactive signals. Read ?page=2 into a signal, write it back with page.set(3), and the browser URL updates automatically — no manual URLSearchParams plumbing, no popstate listeners, no typeof window checks. Each parameter is a fine-grained signal, so a component that reads page() re-renders only when page changes.

@pyreon/url-statestable

Installation

npm install @pyreon/url-state
bun add @pyreon/url-state
pnpm add @pyreon/url-state
yarn add @pyreon/url-state

Peer dependency: @pyreon/reactivity. Optional: @pyreon/router (for router integration).

Quick Start

import { useUrlState } from '@pyreon/url-state'

const page = useUrlState('page', 1)

page()        // 1 — or the value parsed from ?page=… if present
page.set(2)   // URL → ?page=2 (via history.replaceState)
page.reset()  // back to the default (1) — removes ?page from the URL
page.remove() // removes ?page entirely and resets the signal to 1
URL-synced State

The default value (1) does two jobs: it is the value used when the parameter is absent from the URL, and it determines how the raw string is coerced — a number default coerces to a number, a boolean default coerces to a boolean, and so on.

Why URL State?

Some state belongs in the URL: the current page of a paginated list, the active tab, a search query, applied filters, a selected sort order. Putting it there makes the view shareable, bookmarkable, and back-button-friendly — paste the URL to a teammate and they see exactly what you see.

Done by hand, that means reading window.location.search, parsing it, coercing strings to the right types, writing changes back with history.replaceState, listening to popstate for back/forward navigation, and guarding every access for SSR. useUrlState collapses all of that into a single signal:

// ❌ By hand — parsing, coercion, write-back, popstate, SSR guards
function readPage() {
  if (typeof window === 'undefined') return 1
  const raw = new URLSearchParams(window.location.search).get('page')
  return raw === null ? 1 : Number(raw)
}
// ...plus a popstate listener, plus a replaceState writer, plus a signal

// ✅ With useUrlState — one line
const page = useUrlState('page', 1)

Because the result is a signal, it composes with the rest of Pyreon: read it in JSX or a computed, derive from it, pass it to @pyreon/query as part of a query key. The URL becomes just another reactive source.

Single Parameter

useUrlState(key, default) binds one search parameter. The return type matches the default:

const page = useUrlState('page', 1)        // UrlStateSignal<number>
const active = useUrlState('active', true) // UrlStateSignal<boolean>
const q = useUrlState('q', '')             // UrlStateSignal<string>
const tags = useUrlState('tags', ['a'])    // UrlStateSignal<string[]>

Read by calling, write with .set():

function Pagination() {
  const page = useUrlState('page', 1)
  return (
    <div>
      <button onClick={() => page.set(page() - 1)}>Prev</button>
      <span>Page {page()}</span>
      <button onClick={() => page.set(page() + 1)}>Next</button>
    </div>
  )
}

page() is reactive: the <span> re-renders when page changes — including when the user presses the browser back button (see Back/Forward Navigation).

Type Coercion

The serializer/deserializer pair is inferred from the type of the default value. The raw URL string is coerced on read; the value is stringified on write.

Default typeURL stringRead valueNotes
number"42"42Number(raw); falls back to the default if NaN
boolean"true"trueOnly the exact string "true" is true; anything else false
string"hello""hello"Used verbatim
string[]"a,b,c"['a', 'b', 'c']Comma-joined by default — see Array Parameters
object'{"a":1}'{ a: 1 }Auto JSON.stringify / JSON.parse
const page = useUrlState('page', 1)
// URL ?page=42  →  page() === 42  (number)
// URL ?page=xyz →  page() === 1   (NaN → falls back to default)

const active = useUrlState('active', false)
// URL ?active=true  →  active() === true
// URL ?active=1     →  active() === false  (only "true" is true)

Schema Mode

Pass an object to bind several parameters in one call. Each key becomes its own UrlStateSignal:

const params = useUrlState({ page: 1, sort: 'name', q: '' })

params.page()      // 1
params.sort()      // 'name'
params.q()         // ''

params.page.set(2)     // ?page=2
params.sort.set('date') // ?sort=date
params.q.reset()        // removes ?q

Destructuring works too:

const { page, sort, q } = useUrlState({ page: 1, sort: 'name', q: '' })
page.set(2)

Schema mode is exactly useUrlState(key, default) called once per key — each signal is independent. Setting page does not touch sort. Per-key type coercion follows the same rules as single-parameter mode, inferred from each default value.

Options apply to all keys in the schema (the second argument):

const filters = useUrlState({ q: '', sort: 'name' }, { debounce: 300 })
// both q and sort debounce their URL writes by 300ms

Reading Reactively

Components run once in Pyreon — reactivity comes from where you read the signal. Read state() inside JSX, an effect, or a computed to track changes:

const q = useUrlState('q', '')

// ✅ Reactive — the <p> updates when q changes (typing, back button, etc.)
function SearchLabel() {
  return <p>Searching for: {q()}</p>
}

// ✅ Reactive — derive other state from the URL
const isSearching = computed(() => q().length > 0)

// ⚠️ Captured once — `current` never updates after setup
const current = q()

Updating the URL

A UrlStateSignal exposes three mutators:

const page = useUrlState('page', 1)

page.set(5)   // signal → 5, URL → ?page=5
page.reset()  // signal → default (1), URL param removed
page.remove() // signal → default (1), URL param removed, any pending debounce cancelled

set and reset go through the debounce timer (if configured); remove writes immediately and cancels a pending debounced write so a removed parameter can't be resurrected by a stale timer.

Default values clean the URL

When a value equals the default, the parameter is dropped from the URL rather than written as ?page=1. This keeps URLs short and canonical — the default state has no query string:

const page = useUrlState('page', 1)

page.set(3)  // ?page=3
page.set(1)  // back to default → URL has no ?page at all (not ?page=1)

reset() relies on this: it sets the signal to the default, which removes the parameter. remove() force-removes the parameter regardless of the current value.

Keeping the default in the URL

If a parameter must stay in the URL even at its default — for canonical shareable links, or so analytics always sees an explicit value — pass clearOnDefault: false:

const sort = useUrlState('sort', 'name', { clearOnDefault: false })

sort.set('date') // ?sort=date
sort.set('name') // stays ?sort=name (not dropped)

clearOnDefault defaults to true (drop the param at the default). It applies to .set() and .reset(); .remove() always strips the parameter.

Cross-Hook Sync

Two useUrlState('page', 1) calls in different components are independent signals bound to the same parameter — and they stay in sync. A raw history.replaceState does not emit popstate, so this needs coordination the hook provides: after any signal writes a key, every other signal bound to that key re-reads the URL and updates (firing its onChange).

// In <Header/>
const a = useUrlState('page', 1)
// In <Pagination/>
const b = useUrlState('page', 1)

a.set(5)  // b() is now 5 too — b's onChange fires, a's does not

You don't have to lift the signal into a store to share URL state across the tree — binding the same key is enough. (Sharing one signal is still fine and marginally cheaper; cross-hook sync exists for the case where two independently-authored components happen to bind the same param.) The subscription is tied to the owning reactive scope, so a disposed component stops being notified.

Batched Updates

Each .set() writes the URL once. Applying several parameters at once — an "apply filters" button, a saved-view restore — would therefore produce one history operation per parameter. Wrap the writes in batchUrlUpdates to collapse them into a single history entry:

import { useUrlState, batchUrlUpdates } from '@pyreon/url-state'

const { page, q, sort } = useUrlState({ page: 1, q: '', sort: 'name' })

batchUrlUpdates(() => {
  page.set(1)
  q.set('hello')
  sort.set('date')
}) // → one replaceState: ?q=hello&sort=date

Inside a batch:

  • Signal values update synchronouslypage() reflects the new value immediately; only the URL write is deferred to the end of the batch.

  • Reactive notifications coalesce — a subscriber reading several of the params re-runs once, not once per .set().

  • Debounce is bypassed — batched writes land in the single flush, so a debounced signal writes immediately as part of the batch.

  • One history opreplaceState by default; if any write in the batch used replace: false, the single batched write is a pushState.

Options

const q = useUrlState('q', '', {
  debounce: 300,
  replace: true,
  arrayFormat: 'comma',
  serialize: (v) => v,
  deserialize: (raw) => raw,
  onChange: (value) => trackSearch(value),
})
OptionTypeDefaultDescription
replacebooleantruetruehistory.replaceState (no history entry); falsehistory.pushState
debouncenumber0Debounce URL writes by this many milliseconds (signal still updates instantly)
arrayFormat'comma' | 'repeat''comma'Array encoding: ?tags=a,b vs ?tags=a&tags=b
clearOnDefaultbooleantrueDrop the param from the URL when it equals the default. false keeps it written
serialize(value: T) => stringinferredCustom value → string. Must be paired with deserialize
deserialize(raw: string) => TinferredCustom string → value. Must be paired with serialize
onChange(value: T) => voidCalled on external changes only (popstate, or another signal writing the same param)

Debounced Updates

High-frequency inputs (search boxes, sliders) would otherwise fire a replaceState on every keystroke. debounce batches URL writes — the signal updates synchronously (so the input stays responsive), and only the URL write is delayed until the user pauses:

function Search() {
  const q = useUrlState('q', '', { debounce: 300 })
  return (
    <input
      value={q()}                                   // signal updates instantly
      onInput={(e) => q.set(e.currentTarget.value)} // URL updates 300ms after last keystroke
    />
  )
}

Typing hello fires a single URL update 300ms after the last keystroke, not five.

Array Parameters

Arrays of strings are supported with two encodings, selected via arrayFormat:

// Comma format (default): ?tags=react,vue,pyreon
const tags = useUrlState('tags', ['react'], { arrayFormat: 'comma' })

// Repeat format: ?tags=react&tags=vue&tags=pyreon
const tags = useUrlState('tags', ['react'], { arrayFormat: 'repeat' })

tags.set(['react', 'vue']) // updates the URL in the chosen format

Both formats round-trip cleanly:

const tags = useUrlState('tags', [] as string[], { arrayFormat: 'repeat' })
tags.set(['typescript', 'pyreon'])  // ?tags=typescript&tags=pyreon
tags()                              // ['typescript', 'pyreon']

An empty array (or one equal to the default array) removes the parameter, consistent with the default-cleanup rule.

Custom Serializers

For types without built-in coercion, or to control the wire format, supply a serialize/deserialize pair. A common use is compacting an object into a short token:

const filters = useUrlState(
  'f',
  { status: 'active', role: 'admin' },
  {
    serialize: (v) => btoa(JSON.stringify(v)),
    deserialize: (s) => JSON.parse(atob(s)),
  },
)

Your serializer is responsible for producing a URL-safe string. (Plain objects already work via the built-in JSON serializer — reach for a custom pair only when you need a different encoding.)

Back/Forward Navigation

useUrlState listens for the browser's popstate event. Pressing back or forward re-reads the parameter from the URL and updates the signal — so anything reading it re-renders to match the restored URL:

const page = useUrlState('page', 1)
// User: page.set(2) → page.set(3) → presses Back
// → popstate fires → page() reflects the previous URL value

onChange fires for exactly these external updates (popstate, or another useUrlState instance writing the same parameter) — not for your own .set() / .reset() calls:

const q = useUrlState('q', '', {
  onChange: (value) => analytics.track('search_restored', { q: value }),
})

The listener is registered inside an effect, so it's tied to the owning reactive scope: created when the signal is created, and torn down (along with any pending debounce timer) when that scope is disposed.

Router Integration

By default URL writes go through history.replaceState / history.pushState. If you use @pyreon/router, call setUrlRouter(router) once at app setup so URL updates route through the router instead — keeping route guards, middleware, and scroll management consistent:

import { useRouter } from '@pyreon/router'
import { setUrlRouter } from '@pyreon/url-state'

const router = useRouter()
setUrlRouter(router)
// useUrlState now calls router.replace(url) for every write

setUrlRouter takes any object with a replace(path) method (the UrlRouter interface) — you are not forced to use @pyreon/router.

SSR

useUrlState is SSR-safe with no environment checks in your component code:

  • On the server (isClient === false): reads return null, so every signal initializes to its default value. No popstate listener is attached, and no history calls are made.

  • On the client: signals initialize from window.location.search, the popstate listener is registered, and writes hit history (or the router).

// Renders identically on server and client — no typeof window guard needed
function Tabs() {
  const tab = useUrlState('tab', 'overview')
  return <nav class={tab() === 'overview' ? 'active' : ''}></nav>
}

TypeScript

The signal's type is inferred from the default value — no manual annotations needed in the common case:

const page = useUrlState('page', 1)     // UrlStateSignal<number>
const q = useUrlState('q', '')          // UrlStateSignal<string>
const tags = useUrlState('tags', [] as string[]) // annotate empty arrays

The public types are exported for advanced usage:

import type {
  ArrayFormat,     // 'comma' | 'repeat'
  Serializer,      // { serialize, deserialize } pair for a type
  UrlStateOptions, // the options object
  UrlStateSignal,  // the returned signal accessor
} from '@pyreon/url-state'

import type { UrlRouter } from '@pyreon/url-state' // the setUrlRouter argument shape

API Reference

useUrlState(key, default, options?)

Single-parameter overload. Returns a UrlStateSignal<T>.

ParameterTypeDescription
keystringThe URL search-parameter name
defaultValueTValue when the param is absent; its type drives coercion
optionsUrlStateOptions<T>?See UrlStateOptions
function useUrlState<T>(
  key: string,
  defaultValue: T,
  options?: UrlStateOptions<T>,
): UrlStateSignal<T>

useUrlState(schema, options?)

Schema overload. Returns an object mapping each schema key to its own UrlStateSignal.

ParameterTypeDescription
schemaRecord<string, unknown>Object of key → defaultValue
optionsUrlStateOptions?Applied to every parameter in the schema
function useUrlState<T extends Record<string, unknown>>(
  schema: T,
  options?: UrlStateOptions,
): { [K in keyof T]: UrlStateSignal<T[K]> }

setUrlRouter(router)

Configure useUrlState to write URLs through a router instead of the raw history API. Module-level — affects all useUrlState instances. Pass null to clear.

function setUrlRouter(router: UrlRouter | null): void

interface UrlRouter {
  replace(path: string): void | Promise<void>
}

batchUrlUpdates(fn)

Collapse every useUrlState write invoked inside fn into ONE history entry (one replaceState / pushState / router.replace). Returns whatever fn returns. Signal values update synchronously inside the batch; debounce is bypassed; if any write used replace: false, the batched write is a pushState.

function batchUrlUpdates<T>(fn: () => T): T

UrlStateSignal<T>

The callable signal returned for each parameter.

MemberSignatureDescription
()() => TRead the current value reactively
.set(value)(value: T) => voidUpdate the signal and write the URL (debounced if debounce is set)
.reset()() => voidSet the signal to the default; the param is removed from the URL
.remove()() => voidReset the signal to the default and immediately remove the param (cancels any pending debounced write)

UrlStateOptions<T>

PropertyTypeDefaultDescription
replacebooleantruereplaceState (true) vs pushState (false). Ignored when a router is set
debouncenumber0Milliseconds to debounce URL writes (signal updates stay synchronous)
arrayFormatArrayFormat'comma'Array encoding strategy
clearOnDefaultbooleantrueDrop the param when it equals the default; false keeps it written
serialize(value: T) => stringinferredCustom serializer; only used when paired with deserialize
deserialize(raw: string) => TinferredCustom deserializer; only used when paired with serialize
onChange(value: T) => voidCalled on external param changes (popstate / another signal), not on local writes

ArrayFormat

type ArrayFormat =
  | 'comma'  // ?tags=a,b
  | 'repeat' // ?tags=a&tags=b

Serializer<T>

interface Serializer<T> {
  serialize: (value: T) => string
  deserialize: (raw: string) => T
}
URL State