How it works
How @k8ordo/state works underneath. None of it is needed to use the package, but knowing why it is written this way makes the edge cases easier to reason about.
On this page
Definitions are pure; stores live in the browser
A definition is plain data: schemas, or a memory state’s initial values, and pure functions. It holds no store, so a Server Component imports it without creating any state on the server.
shared definePageState / defineLocalState / defineSessionState
defineCookieState / defineMemoryState
↓ import ↓ import
server parseUrl(searchParams) client useAppState(def)
parseCookies(cookies) → [state, update]
href(path, values)The live store is created by the first useAppState, and registered by the definition’s kind and string key. That is why an HMR re-evaluation, which produces a new definition object, reconnects to the state it already had.
Loading the module touches nothing: not navigation, not localStorage, not document.cookie. So a definition imports safely on the server and in tests.
Two definitions of one kind sharing a key are not reported at runtime. An HMR re-evaluation legitimately registers the same key again, so a warning would go off on every edit.
Why there is no Provider
The URL, the history entry, Web Storage and the cookie jar each exist once in the browser. The stores mirror them one to one, so there is nothing for a Provider to scope.
The flip side is that in tests a store outlives each test, which is why every test calls resetStateRegistry().
What the router must do
Only two operations depend on the router; everything else works under any router.
- Links from
hrefandsearch, and GET forms: nothing. The router or the browser handles the click or the submission. - An
update()that leaves the URL alone: nothing. Writes toentry, Web Storage, a cookie or memory involve no navigation. - An
update()that changes the URL: a router that intercepts Navigation API navigations. - Reading
urlon the server: a router that hands the page its query. Under@k8ordo/server, that is thesearchexport.
An update() that changes the URL calls navigation.navigate(). Under @k8ordo/router, pages rendered by @k8ordo/static or @k8ordo/server included, a navigation that keeps the pathname is a state change, not a page change: the router intercepts it without a load, nothing remounts, and scroll and focus stay put. update() has already rendered the new values, so finished settles once that navigation does.
The exception is a page that exports search under @k8ordo/server: when its query moves, the page loads again in place, and finished waits until it is on screen. The pathname is the router’s, and everything from the ? on is this package’s.
Under a router that does not intercept Navigation API navigations, Next.js today for one, the same call is a full document load; change the URL with links and GET forms there. There is deliberately no History API fallback.
A broken value resets only its own field
A value coming back across a boundary is input, not trusted state. It is read in these steps.
- Parse with the whole schema. If that passes, it is done.
- If not, run each field through its own schema, and keep the ones that pass.
- Run what was kept through the whole schema again. An object-level
.refine()runs here. - If that still fails, everything falls back to the defaults, which the definition already proved valid as a whole.
The second whole parse is handed the fields as they arrived, not their outputs, because some fields, such as z.stringbool(), cannot take their own output as input.
A url value written by update() goes into a query string and is read back, the road a visitor’s URL takes, which is what lets a one-way spelling like z.stringbool() work. entry, Web Storage and cookies hand the typed values straight to the schema, which is why there the schema must accept its own output.
Only the keys that changed are told
A definition fixes its set of keys, the schema’s or a memory state’s initial values’, so change can be decided exactly, key by key.
Each field is compared on its own: arrays and plain objects by content, everything else with Object.is. A Date, a Map or a class instance compares by reference, so a new Date for the same moment still counts as a change.
A field that did not change keeps its previous reference, and a subscription to some keys is told only when one of those keys changed.
Shared ground stays shared
The URL and the entry state do not belong to one page state. Other definitions, the router and tracking parameters use them too.
A page state rewrites only its own parameters, and only its own namespace in the entry state. Everything else travels untouched with every write.
What is written is built from the browser’s values at that moment with the batch on top, not from what was rendered, so a write from another tab or definition in between is never rolled back. And when another write arrives while a batch is still pending, the batch stays on top of it.
Cookie writes are asynchronous, so they run one at a time, each reading the cookie after the previous one has landed. Two batches never overwrite each other’s fields with stale values.