@k8ordo/state

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.

CallStateRe-renders when
useAppState(definition)Every fieldAny declared field changes
useAppState(definition, ['q', 'page'])The listed fields onlyA listed field changes
useAppState(definition, []){}Never — write-only
  • The state’s shape follows the kind: the flat merge of url and entry for definePageState, the schema’s output for defineLocalState, the initial values’ type for defineMemoryState.
  • The server render and the hydration render use the defaults rather than the browser’s values — the initial values for defineMemoryState, the passed url values when initialUrl is 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 a definePageState with a url slot; passing it for any other kind is a type error. Seeding the first render
  • As long as the definition lives at module scope, update stays the same function across renders, so listing it in an effect’s dependencies causes no re-runs.
  • AnyState is 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. url fields take the road a URL arrival takes — written into a query and read back — so update({ page: 0 }) on a z.gte(1) field lands on the default 1, exactly as ?page=0 would. A value the schema rejects never renders. Salvage rules
  • A value a URL cannot hold, such as a Date, makes update() 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.
  • defineMemoryState has 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 changesWriteRouter needed
url fields, with or without entry fieldsnavigation.navigate(url, { history, state })One that intercepts the Navigation API
entry fields onlynavigation.updateCurrentEntry({ state })None
defineLocalStateOne localStorage.setItemNone
defineMemoryStateReplaced on the spot, not batchedNone
  • What collapses is the update() calls made synchronously, one after another; an await between two calls makes them separate batches, with separate writes and handles.
  • A definePageState batch that ends where it started neither navigates nor touches the entry. A defineLocalState batch 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.

PromiseResolves when
committedThe write is in its home — the history entry, localStorage, memory
finishedWhatever the router did after the write is done
  • Under @k8ordo/router a navigation that keeps the pathname has no fetch or render behind it, so finished resolves 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 defineMemoryState handle 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 on definePageState, the one kind with a navigation behind it; passing it to a local or memory update() is a type error.
  • If any call in a batch asks for push, the batch’s navigation pushes.
  • A batch that changes entry values but no url value is written with updateCurrentEntry(), and a batch that changes nothing writes nothing, so neither creates a new entry when asked to push.
  • Changing pages goes through navigateTo in @k8ordo/router, which pushes by default; changing state goes through update, 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 useMemo is needed.
  • Whether a field changed is decided structurally: arrays and plain objects by their contents; Date, Map and class instances by reference. A field that did not change keeps its previous reference, so it is safe to hand to memo or 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.

SubscriptionstateRenders
useAppState(def){"a":0,"b":0,"c":0}
useAppState(def, ['a']){"a":0}
useAppState(def, ['b']){"b":0}
Writes to the browser, newest first
None yet

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.