@k8ordo/state
v0.2.0
Published
Declare state by where it lives — URL search params, history-entry state, localStorage, or memory. One zod schema per place, over the Navigation API.
Maintainers
Readme
@k8ordo/state
Declare state by where it lives — URL search params, hidden history-entry state, localStorage, or memory. One zod schema per place derives the server-side read, canonical links, salvage of stale data, and a client subscription with exact per-key change detection. Links and GET forms work before JavaScript loads; imperative URL updates ride the Navigation API.
Like every k8ordo package it assumes React 19 and Server Components, uses only what has reached Baseline newly available, and ships no polyfills or legacy fallbacks.
- Documentation: https://ordo.k8o.me/state
- Design guide: docs/GUIDE.md — shipped inside this package
Installation
npm install @k8ordo/state zod
# or
pnpm add @k8ordo/state zodPeer Dependencies
| Package | Version | Needed for |
| ---------------- | -------- | -------------------------------------------------------- |
| react | ≥19.2.6 | useAppState |
| zod | ^4.4.3 | the schemas (zod/mini works, and is the lighter pick) |
| @k8ordo/router | ^0.1.0 | optional — typed href paths from the app's route table |
| typescript | ≥7.0.2 | the shipped type declarations |
| @types/react | ≥19.2.18 | the shipped type declarations |
The schema ships to the browser here — the client parses and serializes with
it — so reach for zod/mini unless the app already pays for classic zod.
@k8ordo/router is a type-only peer: it is never loaded at runtime.
Quick Start
One definition per place, in a shared module with no 'use client':
// state.ts
import { defineLocalState, definePageState } from '@k8ordo/state';
import * as z from 'zod/mini';
export const listState = definePageState('list', {
url: z.object({
q: z._default(z.string(), ''),
page: z._default(z.coerce.number(), 1),
}),
});
export const prefs = defineLocalState(
'prefs',
z.object({ view: z._default(z.enum(['grid', 'table']), 'grid') }),
);// page.tsx — Server Component
import { listState } from './state';
export default async function Page({ searchParams }: PageProps<'/products'>) {
const url = listState.parseUrl(await searchParams); // typed, defaults applied
const products = await fetchProducts(url);
return (
<>
<Filters initialUrl={url} />
<ProductList products={products} />
<a href={listState.href('/products', { ...url, page: url.page + 1 })}>
next
</a>
</>
);
}// filters.tsx
'use client';
import { useAppState } from '@k8ordo/state';
import { listState, prefs } from './state';
export function Filters({ initialUrl }: FiltersProps) {
const [{ q, page }, update] = useAppState(listState, { initialUrl });
const [{ view }, updatePrefs] = useAppState(prefs, ['view']);
return (
<>
<p>{q === '' ? 'all products' : `searching “${q}”`}</p>
<button type="button" onClick={() => update({ page: page + 1 })}>
next page
</button>
<button type="button" onClick={() => updatePrefs({ view: 'table' })}>
table view
</button>
</>
);
}update() applies synchronously, batches per handler into one write, and
returns { committed, finished } handles. href omits every field at its
default, so every link is canonical. A value the schema rejects — a URL a
user edited, a row an older schema wrote — falls back to its own default,
field by field.
The design guide covers the rest: the four places and when
each fits, history-entry state, batching and update handles, subscription
granularity, router requirements, typed routes via Register, reading local
state before hydration with inlineRead(), and GET forms with @k8ordo/form.
AI Agent Documentation
The docs ship inside the package, so an agent always reads the exact version you installed — there is no snapshot to copy or re-sync on upgrade.
Point your agent at them once by pasting this into your project's CLAUDE.md /
AGENTS.md:
Use `@k8ordo/state` for URL, history-entry, localStorage and shared memory
state. Before adding or changing state, read
`node_modules/@k8ordo/state/docs/GUIDE.md`. Declare each state by where it
lives (`definePageState` / `defineLocalState` / `defineMemoryState`) in a
shared module, read the url slot on the server with `parseUrl`, build links
with `href`, and subscribe on the client with `useAppState`. Every boundary
field needs a default or `.optional()`; never mirror a definition's values
into React state.What each surface gives an agent:
| Surface | Where |
| -------------------------- | ---------------------------------------------- |
| Design guide (entry point) | node_modules/@k8ordo/state/docs/GUIDE.md |
| Docs index for LLMs | docs/llms.txt · https://ordo.k8o.me/llms.txt |
| Markdown twin on the web | https://ordo.k8o.me/state/docs/GUIDE.md |
License
MIT License - see LICENSE for details.
