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.tsimport { 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, orz.enum()for a fixed set of choices. - Numbers:
z.coerce.number(), which turns the"2"of?page=2into2. - Booleans:
z.stringbool(), which reads"true"astrueand writestrueback 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.
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().
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.
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.tsexport 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.
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.
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"page2inStockfalsetags[]
- Rebuilt query
q=lamp&page=2
Try it
- Change
page=2topage=0. Onlypagefalls back to its default1, andqstays"lamp". - Add
&inStock=yesto the end.inStockbecomestrue, and the rebuilt query spells itinStock=true. - Add
&tags=sale&tags=newas well, andtagsreads as an array. Changenewtoold, a value outside the choices, and the whole oftagsgoes back to[].