Updates
useAppState is the same hook for every place. The update() it returns validates what you write and renders it on the spot, collapses the writes, and routes each field to where it lives.
useAppState
Call it from a client component; it returns [state, update]. There is no Provider: the browser’s store is created on first use and registered under the definition’s kind and key.
| Call | State | Re-renders when |
|---|---|---|
useAppState(definition) | Every field | Any declared field changes |
useAppState(definition, ['q', 'page']) | The listed fields only | A listed field changes |
useAppState(definition, []) | {} | Never — write-only |
- The state’s shape follows the kind: the flat merge of
urlandentryfordefinePageState, the schema’s output fordefineLocalState, the initial values’ type fordefineMemoryState. - The server render and the hydration render use the defaults rather than the browser’s values — the initial values for
defineMemoryState, the passedurlvalues wheninitialUrlis given — and the real values arrive in the render after. - The
{ initialUrl }options object — the third argument, or the second when you leave out the keys — exists only for adefinePageStatewith aurlslot; passing it for any other kind is a type error. Seeding the first render - As long as the definition lives at module scope,
updatestays the same function across renders, so listing it in an effect’s dependencies causes no re-runs. AnyStateis the union of the three definition types, for typing a helper that takes any definition.
update(patch)
Pass an object holding only the fields to change. To derive the next value from the current one, pass a function: its argument is the state including updates from the same batch that have not been written yet.
// src/catalog/tag-filter.tsx
'use client';
import { useAppState } from '@k8ordo/state';
import { catalogState } from '../state/catalog';
type Props = {
tags: readonly string[];
};
export function TagFilter({ tags }: Props) {
const [{ tags: selected }, update] = useAppState(catalogState, ['tags']);
return (
<div>
{tags.map((tag) => (
<button
aria-pressed={selected.includes(tag)}
key={tag}
onClick={() => {
update((current) => ({
tags: current.tags.includes(tag)
? current.tags.filter((other) => other !== tag)
: [...current.tags, tag],
page: 1,
}));
}}
type="button"
>
{tag}
</button>
))}
<button
onClick={() => {
update({ tags: [] });
update({ page: 1 });
}}
type="button"
>
Clear
</button>
</div>
);
}update()applies synchronously — the next render sees the new values — and the write itself follows, batched.- The patch goes through the schema on the spot.
urlfields take the road a URL arrival takes — written into a query and read back — soupdate({ page: 0 })on az.gte(1)field lands on the default1, exactly as?page=0would. A value the schema rejects never renders. Salvage rules - A value a URL cannot hold, such as a
Date, makesupdate()itself throw before anything is written: a rejected handle would go unnoticed, since most callers never await it. - A field the definition does not have is a type error, and one that slips past the types throws a
TypeError. defineMemoryStatehas no schema, so what you pass is what it holds.
How writes are batched
On definePageState and defineLocalState, the update() calls made in one handler collapse into one write per definition, and they all return the same handle — the two calls behind Clear above make one navigation. defineMemoryState has no write to batch: each call applies on the spot and returns its own settled handle. Where the write goes depends on which fields the batch actually changed.
| The batch changes | Write | Router needed |
|---|---|---|
url fields, with or without entry fields | navigation.navigate(url, { history, state }) | One that intercepts the Navigation API |
entry fields only | navigation.updateCurrentEntry({ state }) | None |
defineLocalState | One localStorage.setItem | None |
defineMemoryState | Replaced on the spot, not batched | None |
- What collapses is the
update()calls made synchronously, one after another; anawaitbetween two calls makes them separate batches, with separate writes and handles. - A
definePageStatebatch that ends where it started neither navigates nor touches the entry. AdefineLocalStatebatch writes its row even then, creating it if none was stored. - The URL and the entry state are shared ground. A write rewrites only its own params and its own namespace; params owned by other definitions or by nobody, like
utm_source, and everything else in the entry state survive every write. - What is written is built from what the browser holds at that moment — the URL, the entry state, localStorage — with the batch’s changes on top, not from the rendered snapshot, so it never rolls back what another tab or another definition wrote in between.
The handle: committed and finished
update() returns the shape navigation.navigate() does: an object holding two promises, typed UpdateHandle. It is not a promise itself, so ignoring it — the normal case — trips no floating-promise lint.
| Promise | Resolves when |
|---|---|
committed | The write is in its home — the history entry, localStorage, memory |
finished | Whatever the router did after the write is done |
- Under
@k8ordo/routera navigation that keeps the pathname has no fetch or render behind it, sofinishedresolves once that navigation settles;update()already rendered the new values. - A write with no navigation behind it — entry-only, local, a page batch that changed nothing — settles its handle when the batch is flushed, in a microtask right after the handler; a
defineMemoryStatehandle is already settled when it is returned. - A navigation overtaken by a later one rejects its handle with an
AbortError. A failed localStorage write — a full quota, say — rejects too, while the rendered value stays. Neither surfaces as an unhandled rejection when nobody awaits the handle.
Code that has to wait for the write awaits finished. The example below moves focus to the heading once the next page is in place.
// src/catalog/results.tsx
'use client';
import { useAppState } from '@k8ordo/state';
import { useRef } from 'react';
import { catalogState } from '../state/catalog';
const isAbort = (error: unknown) =>
error instanceof DOMException && error.name === 'AbortError';
export function Results() {
const [{ page }, update] = useAppState(catalogState, ['page']);
const heading = useRef<HTMLHeadingElement>(null);
return (
<section>
<h2 ref={heading} tabIndex={-1}>
Page {page}
</h2>
<button
onClick={async () => {
try {
await update({ page: page + 1 }, { history: 'push' }).finished;
} catch (error) {
if (!isAbort(error)) throw error;
return;
}
heading.current?.focus();
}}
type="button"
>
Next page
</button>
</section>
);
}When the update you await can be overtaken, ignore its AbortError and rethrow anything else, as the example does.
An async action — startTransition(async …), useTransition’s included, or the onAction of @k8ordo/ui’s Button — can await it the same way. Under @k8ordo/router an update that changes a url value while another page is still loading is a page change, and a page change never joins the action, so finished settles once that page is on screen.
history: replace and push
The default is replace: an update refines the current entry, and the back button has no business undoing it step by step. Pass { history: 'push' } only for updates the back button should undo — paging, or a tab switch you want treated as a step.
- The option — typed
UpdateOptions— exists only ondefinePageState, the one kind with a navigation behind it; passing it to a local or memoryupdate()is a type error. - If any call in a batch asks for
push, the batch’s navigation pushes. - A batch that changes
entryvalues but nourlvalue is written withupdateCurrentEntry(), and a batch that changes nothing writes nothing, so neither creates a new entry when asked topush. - Changing pages goes through
navigateToin@k8ordo/router, which pushes by default; changing state goes throughupdate, which replaces.
Subscription granularity
The definition fixes the key set — its schemas’ keys, or a memory state’s initial values — so change detection is exact, per key. A component subscribed to ['q'] never re-renders when page changes.
- Write the key array inline; it is normalized internally, so no
useMemois needed. - Whether a field changed is decided structurally: arrays and plain objects by their contents;
Date,Mapand class instances by reference. A field that did not change keeps its previous reference, so it is safe to hand tomemoor a dependency array. - When two pieces of state update at very different rates, give them separate definitions: the definition is the subscription boundary.
Watch it happen
The demo below is a real definePageState keeping a and b in the URL and c in the history entry. The buttons live in a write-only component that subscribes to nothing (useAppState(def, [])); below them are the render counts of three subscriptions and the writes the browser actually received.
| Subscription | state | Renders |
|---|---|---|
useAppState(def) | {"a":0,"b":0,"c":0} | |
useAppState(def, ['a']) | {"a":0} | |
useAppState(def, ['b']) | {"b":0} |
a + 1 leaves the render count of the ['b'] subscription alone. a + 1 (×3) is three update() calls but one navigation in the log. c + 1 is an updateCurrentEntry, not a navigation. a = -1 is rejected by the schema (z.gte(0)) and lands on the default 0 — and when a is already 0, nothing is written at all.
Do not write every keystroke to the URL
Let the DOM or local React state hold a draft, and call update() at commit points — submit, blur, paging. It is the same line @k8ordo/form draws. Do not mirror a definition’s values into React state and keep the two in sync.
// src/catalog/search-box.tsx
'use client';
import { useAppState } from '@k8ordo/state';
import { catalogState } from '../state/catalog';
export function SearchBox() {
const [{ q }, update] = useAppState(catalogState, ['q']);
return (
<form
onSubmit={(event) => {
event.preventDefault();
const value = new FormData(event.currentTarget).get('q');
update({ q: typeof value === 'string' ? value : '', page: 1 });
}}
>
<input
aria-label="Search"
defaultValue={q}
key={q}
name="q"
type="search"
/>
<button type="submit">Search</button>
</form>
);
}key={q} rebuilds the uncontrolled input with the new defaultValue when q changes from outside — the back button, for instance.