@k8ordo/state

Places

Each definition names one place, and the place decides how long the values live, who sees them and whether the server can read them. This page covers how the four places differ and how to write the schema for each.

The four places

url and entry are the two faces of one history entry — one visible and shareable, one hidden — so they share a definition and update atomically. localStorage and memory belong to the app rather than the page, which is why they are definition kinds of their own.

DefinitionLives inSurvivesShared withServer
definePageState · urlsearch paramsback/forward, shared linksanyone given the URLreads it with parseUrl (under a router that hands it the search)
definePageState · entryhistory entry stateback/forward, reloadthat entry of that tabrenders the defaults
defineLocalStatelocalStorageuntil deletedevery tab of the site in the same browserrenders the defaults
defineMemoryStatethe JavaScript runtimeuntil reloadthat tabrenders the initial values

Choosing

  • URL — whatever a link should reproduce: a search term, filters, a page number, the selected tab. Values the server renders from go here too.
  • Entry — whatever back and forward should bring back but a shared link should not carry: which rows are expanded, whether details are showing, UI state that belongs to this visit of the page.
  • localStorage — the preferences of whoever uses the device: a view mode, a page size, a colour scheme.
  • Memory — whatever distant components share but a reload may discard: whether a command palette is open, a debug panel.

Try both faces

The demo below is a real definePageState that keeps scope in the URL and the open rows in the history entry.

scope
URL
no query (the default)
entry
{"state-places-demo":{"open":[]}}

Opening a row leaves the URL alone — it is written into the current entry with updateCurrentEntry. Switching scope pushes a new entry and carries the open rows into it. Press back and scope and the rows you had open come back together. Both survive a reload, but paste the URL into a new tab and only scope comes along.

The url slot

State kept in the search params. A URL carries only strings, so the schema has to be one that reads its own types back out of a string.

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

export const catalogState = definePageState('catalog', {
  url: z.object({
    q: z._default(z.string(), ''),
    page: z._default(z.coerce.number().check(z.int(), z.gte(1)), 1),
    tags: z._default(z.array(z.string()), []),
    sort: z._default(z.enum(['new', 'price']), 'new'),
  }),
});
  • Numbers: z.coerce.number(), so the "2" of ?page=2 becomes 2.
  • Booleans: z.stringbool(), which reads "false" as false and writes false as "false".
  • Arrays: the param repeated (?tags=sale&tags=new), and the only default allowed is [].
  • A param repeated on a non-array field reads its first value.
  • What a URL can hold: strings, numbers, bigints, booleans, and arrays of those.
  • A field at its default is never written into the query: even if someone types ?page=1, the next update() that changes a url field leaves it out.

A definition holding a boolean, an array of enum values and a date looks like this:

// src/state/map.ts
import { definePageState } from '@k8ordo/state';
import * as z from 'zod/mini';

export const mapState = definePageState('map', {
  url: z.object({
    zoom: z._default(z.coerce.number().check(z.int(), z.gte(1), z.lte(20)), 12),
    satellite: z._default(z.stringbool(), false),
    layers: z._default(z.array(z.enum(['traffic', 'transit'])), []),
    since: z.optional(z.iso.date()),
  }),
});

Spellings that are refused

update() checks the values it writes by the road they will come back on: written into a query string and read again. A field that cannot read back its own query-string spelling would land on its default after every write, so the spellings certain to do that are refused when the module loads rather than at the first click.

WrittenUse insteadWhy
z.boolean() / z.coerce.boolean()z.stringbool()A URL carries strings, and "false" is not false to z.boolean()z.coerce.boolean() reads it as true
An array defaulting to anything but [], or a z.optional() arrayz._default(z.array(…), [])An absent param and an empty list are the same URL, so with any other default [] could never be written
A field with neither z._default() nor z.optional()z._default() / z.optional()A param can always be missing. This rule holds for every place with a schema, not just url
z.date()A string field such as z.iso.date()A URL has no spelling for it. This one throws when something writes a value (href, search, update()), not at definition time

The entry slot

Hidden state attached to the history entry. It never shows in the URL; it is stored in the Navigation API’s entry state (navigation.currentEntry.getState()) under the definition’s key as its namespace.

// src/state/order-panel.ts
import { definePageState } from '@k8ordo/state';
import * as z from 'zod/mini';

export const orderPanelState = definePageState('order-panel', {
  entry: z.object({
    expanded: z._default(z.array(z.string()), []),
    showTotals: z._default(z.boolean(), false),
  }),
});
  • Values are not turned into strings — the entry keeps them as they are — so z.number() and z.boolean() work as written, with none of the url slot’s spelling restrictions. The schema must still accept its own output (see Schema rules below).
  • An update() that changes only entry fields does not navigate: it rewrites the current entry with navigation.updateCurrentEntry(), which is why it works under any router. It also never creates a history entry, and { history: 'push' } is ignored there. State the back button should step back through — the steps of a wizard, say — belongs in url.
  • Entry state does not exist on the server, so the server render and the hydration render use the defaults.
  • Values an older schema wrote, brought back by a session restore, are treated as input too: any field the schema rejects falls back to its default.

Both in one definition

Declare both in one definition and the state useAppState returns is the flat merge of the two slots. Moving a field from one slot to the other changes only the definition, and nothing at the call sites.

// src/state/orders.ts
import { definePageState } from '@k8ordo/state';
import * as z from 'zod/mini';

export const ordersState = definePageState('orders', {
  url: z.object({
    status: z._default(z.enum(['open', 'shipped']), 'open'),
  }),
  entry: z.object({
    expanded: z._default(z.array(z.string()), []),
  }),
});
// src/orders/order-tabs.tsx
'use client';

import { useAppState } from '@k8ordo/state';

import { ordersState } from '../state/orders';

export function OrderTabs() {
  const [{ status }, update] = useAppState(ordersState, ['status']);

  return (
    <button
      onClick={() => {
        update(
          { status: status === 'open' ? 'shipped' : 'open', expanded: [] },
          { history: 'push' },
        );
      }}
      type="button"
    >
      {status === 'open' ? 'Show shipped' : 'Show open'}
    </button>
  );
}
  • An update that spans both slots hands the URL and the entry state to a single navigation.navigate(), so a half-applied state is never visible, and the back button restores both together.
  • A navigation update() makes to rewrite the URL carries the current entry state into the new entry, including other definitions’ namespaces and anything else the entry state holds.
  • Declaring a field name in both slots is a type error that names the field, and it throws at runtime too. So does a definition with neither slot.
  • The per-slot rules do follow the field, though: z.boolean() in entry becomes z.stringbool() in url, and back to z.boolean() when it moves to entry. entry hands the typed value straight back to the schema, so a z.stringbool() left there lands on its default on every update().

defineLocalState

App-wide state kept in localStorage: shared by the tabs of the same browser and kept until deleted.

// src/state/prefs.ts
import { defineLocalState } from '@k8ordo/state';
import * as z from 'zod/mini';

export const prefsState = defineLocalState(
  'prefs',
  z.object({
    view: z._default(z.enum(['grid', 'table']), 'grid'),
    pageSize: z._default(z.number().check(z.int(), z.gte(10), z.lte(100)), 20),
  }),
);
  • The values are stored as one row under k8ordo-state:<key> — the definition’s storageKey — as JSON holding only the fields the schema declares. For the definition above, that is k8ordo-state:prefs with a row like {"view":"grid","pageSize":20}.
  • Storage goes through JSON, so keep the fields to types JSON can represent. A z.date() value shows right after the write, but the next load finds a string and falls back to the default. As in entry, the schema must accept its own output.
  • Writes from other tabs arrive through the storage event, and only components subscribed to a changed key re-render.
  • A row an older schema wrote is salvaged field by field, and corrupt JSON starts from the defaults.
  • The server has no localStorage, so the server render and the hydration render use the defaults. When a value is needed before the first paint, read it before hydration. Reading before hydration

defineMemoryState

A typed shared box that lives in the JavaScript runtime: shared within the tab, back to its initial values on reload.

// src/state/command-palette.ts
import { defineMemoryState } from '@k8ordo/state';

export const commandPaletteState = defineMemoryState<{
  open: boolean;
  query: string;
  scope: 'all' | 'pages' | 'actions';
}>('command-palette', { open: false, query: '', scope: 'all' });
// src/command-palette/palette-button.tsx
'use client';

import { useAppState } from '@k8ordo/state';

import { commandPaletteState } from '../state/command-palette';

export function PaletteButton() {
  const [{ open }, update] = useAppState(commandPaletteState, ['open']);

  return (
    <button
      aria-expanded={open}
      onClick={() => {
        update({ open: !open, query: '' });
      }}
      type="button"
    >
      Commands
    </button>
  );
}
  • The one kind without a schema: its values never come back across a boundary, and the typed update() is the only writer, so there is nothing to re-validate. The type is inferred from the initial values; a type they cannot express, such as a union, goes in the type argument as above.
  • Treat the values as immutable. Change detection compares the fields update() receives with the previous values, so mutating a nested object in place notifies nobody.
  • The field set is fixed by the keys of the initial values. update() applies immediately, with no batching, and the server renders the initial values.

The key is the identity

A definition’s first argument is the identity of the state.

  • The browser’s store is registered under this string. Because it is looked up by the string rather than by the definition object, a module re-evaluated by HMR reconnects to the state it already had.
  • For definePageState, it is the namespace inside the entry state.
  • For defineLocalState, it becomes the localStorage key k8ordo-state:<key>.

Renaming the key renames the data. Two definitions of the same kind that share a key silently share one store — and, for local state, one storage row. The module system cannot catch this, so treat the key as an app-wide global name.

Schema rules

Schemas appear exactly where data comes back across a boundary: a URL the user can edit, localStorage an older schema wrote, entry state a session restore brought back. What comes from there is treated as input, not as trusted state.

  • A schema is a z.object(), written with either zod or zod/mini.
  • Every field must parse from nothing. A field with neither z._default() (.default()) nor z.optional() throws at definition time, naming the field. The default of a z.optional() field is undefined.
  • An object-level refine must accept the value where every field is at its default, or the definition throws.
  • In entry and localStorage the schema must accept its own output as input: stored values come back typed and go through the schema again, so a z.stringbool() or a type-changing transform lands on its default on every write. In url the values come back through the query string, which is why z.stringbool() works there.
  • A value the schema rejects falls back to that field’s own default, and reading never throws. Worked salvage examples

zod or zod/mini

Parsing runs on zod’s shared core, so a schema written with either entry works. The client parses and serializes with the schema itself, so the module holding it ships to the browser: choose zod/mini unless the app already pays for classic zod. This is where it differs from @k8ordo/form, whose schema stays on the server.

The same definition, written both ways:

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

export const catalogState = definePageState('catalog', {
  url: z.object({
    q: z._default(z.string(), ''),
    page: z._default(z.coerce.number().check(z.int(), z.gte(1)), 1),
    tags: z._default(z.array(z.string()), []),
    sort: z._default(z.enum(['new', 'price']), 'new'),
  }),
});
// src/state/catalog.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),
    tags: z.array(z.string()).default([]),
    sort: z.enum(['new', 'price']).default('new'),
  }),
});

What a definition holds

A definition is an object of schemas (initial values, for memory) and pure functions. Every type below is exported from @k8ordo/state.

TypeHolds
PageStatekind: 'page', key, url, entry, parseUrl, href, search. url and entry are the schemas as passed (undefined for the one left out)
LocalStatekind: 'local', key, schema, storageKey, inlineRead
MemoryStatekind: 'memory', key, initial. initial is a shallow copy of the values passed
StateSchemaThe schema type url, entry and defineLocalState accept: what a z.object() from zod and one from zod/mini have in common
OutputOfA schema’s output type, or an empty object type for undefined. Use it for props: OutputOf<typeof catalogState.url>