@k8ordo/state

State in the history entry

Some state should come back with the back button, yet stay out of a shared link: an open row, an expanded panel. That state goes in entry, the hidden face of the history entry.

On this page

Write it in entry

Hand definePageState an entry schema. The values never show in the URL: they are kept in the state the Navigation API holds for each history entry, under the definition’s key.

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

export const ordersState = definePageState('orders', {
  entry: z.object({
    expanded: z.array(z.string()).default([]),
    showNotes: z.boolean().default(false),
  }),
});

The values are kept typed, never turned into strings, so a boolean is a plain z.boolean() and a number a plain z.number().

The schema must accept its own output, though, because a written value passes the schema again as the typed value it is. A z.stringbool() or a type-changing transform lands on its default on every write.

The server has no history entry, so the server render and the hydration render show the defaults.

Put url and entry in one definition

One definition can hold both url and entry. The state useAppState returns is the two merged flat.

tsx
export const ordersState = definePageState('orders', {
  url: z.object({
    status: z.enum(['all', 'pending', 'shipped']).default('all'),
  }),
  entry: z.object({
    expanded: z.array(z.string()).default([]),
  }),
});

const [{ status, expanded }, update] = useAppState(ordersState);

Moving a field from url to entry changes the definition and nothing at the call sites. The spelling follows the slot, though: a boolean that was z.stringbool() in url becomes z.boolean() in entry.

Declaring the same field in both is a type error, and at runtime it throws: "orders" declares in both url and entry: status.

Both faces are written at once

One update() can change both url and entry. The two faces sit on the same history entry, so what changed decides how it is written.

ts
update({ status: 'pending' }, { history: 'push' });
One navigation.navigate()

update({ expanded: ['A-102'] });
updateCurrentEntry() on the current entry
  • When a url value changes: one navigation.navigate() carries the new URL and the entry state together, so a screen with only one of them changed is never seen.
  • When only entry values change: navigation.updateCurrentEntry() rewrites the current entry. No navigation is involved, so it works under any router, and { history: 'push' } creates no new entry.

A write that changes the URL carries the current entry values over to the new entry, along with whatever other definitions keep in the entry’s state.

What back, forward and reload bring back

Both faces live on the browser’s history entry, not in a record the library keeps on the side, so what the browser does applies to both.

  • Back and forward: the url and entry of the entry you land on come back together.
  • Reload: the page loads again on the same entry, so both stay.
  • Opening the URL in a new tab: only the URL travels. url has the same values, and entry starts from its defaults.
  • Following a link: the new entry has no entry values yet, so they start from the defaults.
  • Session restore: values an older schema wrote may come back. A value the schema rejects falls back to that field’s default alone.
Playground

Compare the two faces

A real definePageState that rewrites this page’s URL and history entry. The filter lives in url, and the open orders in entry.

Filter
  • Shipped
  • Pending
  • Pending
url
no query
entry
{"expanded":[]}

Try it

  1. Open “A-102”. The entry value changes, and the URL’s query does not.
  2. Switch the filter to “Pending”. The query becomes ?status=pending, and “A-102” stays open.
  3. Open “A-103” too, then press the browser’s back button. The filter returns to “All”, and only “A-102” is open again.
  4. Reload the page. The filter and the open orders are still there.
k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2