@k8ordo/state

State in the URL

State in the URL shows the same screen to whoever you send the link to. In exchange, a URL carries only strings, and anyone can edit it. This page covers how to write the url schema, and how an edited value is read.

On this page

Let every field be missing

A URL parameter can always be missing. Someone trims the link by hand, or opens a link made before the field existed.

So every field gets a .default() or an .optional(). Forget one, and the definition throws as the module loads, naming the field: you find out before the first click, not after.

A field with no sensible default takes .optional(), and reads as undefined when its parameter is absent.

Read types out of strings

A URL carries only strings, so each field is written to read its own type out of one.

catalog-state.ts
import { definePageState } from '@k8ordo/state';
import * as z from 'zod';

export const catalogState = definePageState('catalog', {
  url: z.object({
    q: z.string().default(''),
    page: z.coerce.number().int().min(1).default(1),
    inStock: z.stringbool().default(false),
    tags: z.array(z.enum(['sale', 'new'])).default([]),
  }),
});
  • Strings: z.string() as it is, or z.enum() for a fixed set of choices.
  • Numbers: z.coerce.number(), which turns the "2" of ?page=2 into 2.
  • Booleans: z.stringbool(), which reads "true" as true and writes true back as "true".
  • Arrays: z.array(), written as the same parameter repeated (?tags=sale&tags=new), with [] as the default.

When the parameter of a field that is not an array is repeated, the first value is read. Parameters the schema does not declare are left alone, so they sit happily next to something like utm_source.

What is refused as the module loads

update() writes the values into a query string and reads them back before rendering, the same road a visitor’s URL takes. A field that cannot read back its own query would land on its default on every write, so the spellings certain to do that are refused when the module loads.

ts
inStock: z.boolean().default(false),
inStock: z.stringbool().default(false),

The first is z.boolean() and z.coerce.boolean(). The URL’s "false" is no boolean to z.boolean(), and is true to z.coerce.boolean(). They are refused as an array’s item too, with an error beginning url boolean fields must use z.stringbool().

ts
tags: z.array(z.string()).optional(),
tags: z.array(z.string()).default([]),

The second is an array that is .optional() or defaults to anything but []. An absent parameter and an empty list are the same URL, so with any other default an empty list could never be written. The error begins url array fields must default to [].

A value no URL can spell is refused the moment something writes it. Hand a z.date() field a Date, and update() or href throws, naming the field.

Read a hand-edited URL

Anyone can edit a URL, so values the schema rejects do arrive. Such a value falls back to that field’s default while the other fields keep what they read, and reading never throws.

ts
const read = (query: string) =>
  catalogState.parseUrl(new URLSearchParams(query));

read('q=lamp&page=0');
// { q: 'lamp', page: 1, inStock: false, tags: [] }

read('page=abc&inStock=true');
// { q: '', page: 1, inStock: true, tags: [] }

read('tags=sale&tags=new');
// { q: '', page: 1, inStock: false, tags: ['sale', 'new'] }

read('tags=sale&tags=old');
// { q: '', page: 1, inStock: false, tags: [] }

read('q=red&q=blue');
// { q: 'red', page: 1, inStock: false, tags: [] }

An array counts as one field: a single rejected item resets the whole array to [].

A .refine() on the whole schema runs once more over what the fields salvaged. When it rejects the combination, every field falls back to its default, so the refine must accept the all-defaults value. If it does not, the definition throws as the module loads.

price-state.ts
export const priceState = definePageState('price', {
  url: z
    .object({
      min: z.coerce.number().min(0).default(0),
      max: z.coerce.number().min(0).default(1000),
    })
    .refine((range) => range.min <= range.max),
});

priceState.parseUrl(new URLSearchParams('min=500&max=100'));
// { min: 0, max: 1000 }

One URL per state, defaults left out

A field at its default is left out of the query. The same state always gives the same, shortest URL, so links, bookmarks and cache keys agree.

ts
catalogState.search({ page: 1, tags: ['sale'] });
// 'tags=sale'

catalogState.search({ tags: ['sale'], q: 'desk lamp' });
// 'q=desk+lamp&tags=sale'

Parameters follow the order the schema declares them in. A URL with a hand-written ?page=1 reads the same, and the next update() that changes a url value rewrites the query without it.

Playground

Try reading a URL

The query you type is read by parseUrl on the same definition as catalogState above. Below it is the query search writes back from what was read. This page’s own URL is left alone.

q
"lamp"
page
2
inStock
false
tags
[]
Rebuilt query
q=lamp&page=2

Try it

  1. Change page=2 to page=0. Only page falls back to its default 1, and q stays "lamp".
  2. Add &inStock=yes to the end. inStock becomes true, and the rebuilt query spells it inStock=true.
  3. Add &tags=sale&tags=new as well, and tags reads as an array. Change new to old, a value outside the choices, and the whole of tags goes back to [].
k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2