Get Started
@k8ordo/state declares state by where it lives. This page defines one piece of URL state and follows it all the way through: installed, defined once, used from a component, read on the server.
The idea
Usually you pick a store first and bolt persistence or URL syncing on later. Here the order is reversed: you name the place first. The place fixes the lifetime (when the values go away) and the reach (who sees them). Each place where values come back across a boundary — the URL, the history entry, localStorage — has one schema, and from it derive the server-side read, canonical links, salvage of stale data and a per-key subscription. Memory never crosses a boundary, so it is a typed box with no schema.
- The
urlslot ofdefinePageState— the URL’s search params. Shareable as a link, and readable on the server under a router that hands it the search. - The
entryslot ofdefinePageState— hidden history-entry state. Restored by back and forward, never in the URL. defineLocalState— localStorage. Shared by every tab of the site in the same browser, kept until deleted.defineMemoryState— the JavaScript runtime. A typed shared box that resets on reload.
There is no Provider. The URL, the history entry and localStorage each exist only once in the browser, and the stores mirror them one to one, so there is nothing to scope.
Installation
Install it together with zod. The schema reaches the browser too, so reach for the lighter zod/mini unless the app already pays for classic zod.
npm install @k8ordo/state zodPeer dependencies
| Package | Version | Needed for |
|---|---|---|
| react | ≥19.3.0 | useAppState |
| zod | ^4.4.3 | The schemas. Both zod and zod/mini work |
| @k8ordo/router | ^0.1.0 | Optional. Typed href paths from the route table. Types only; never loaded at runtime |
| typescript | ≥7.0.2 | Optional. The shipped type declarations |
| @types/react | ≥19.3.0 | Optional. The shipped type declarations |
In the browser the Navigation API is assumed. It has reached Baseline newly available, so there is no polyfill and no fallback.
Define it once
Put the definition in a module without the use client directive and import it from Server Components and client components alike. A definition is plain data — schemas and pure functions — and holds no store, so importing it on the server creates no server-side state.
// src/state/products.ts
import { definePageState } from '@k8ordo/state';
import * as z from 'zod/mini';
export const productListState = definePageState('product-list', {
url: z.object({
q: z._default(z.string(), ''),
page: z._default(z.coerce.number().check(z.int(), z.gte(1)), 1),
sort: z._default(z.enum(['new', 'price']), 'new'),
}),
});- Exported from a
use clientfile, it would reach a Server Component as a client reference instead of the value, and neitherparseUrlnorhrefcould be called. - The first argument,
'product-list', is the state’s identity: it names the store and where the values are kept. Two definitions of the same kind sharing a key silently share one store, so treat it as an app-wide global name. - A URL param can always be missing, so every field must parse from nothing: give it
z._default()(.default()in classic zod) orz.optional(). A field without one throws when the module loads, naming the field. A URL carries only strings, so a number is read withz.coerce.number().
Use it in a component
useAppState(definition) is the same hook for every place and returns [state, update]. What update() writes shows in the very next render, and the URL write collapses every call in the same handler into one navigation.
// src/routes/products/_parts/product-list.tsx
'use client';
import { useAppState } from '@k8ordo/state';
import type { Product } from '../../../data/products';
import { productListState } from '../../../state/products';
const PAGE_SIZE = 20;
type Props = {
products: readonly Product[];
};
export function ProductList({ products }: Props) {
const [{ q, page, sort }, update] = useAppState(productListState);
const visible = products
.filter((product) => product.name.includes(q))
.toSorted((a, b) =>
sort === 'price' ? a.price - b.price : b.createdAt - a.createdAt,
)
.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return (
<section>
<button
onClick={() => {
update({ sort: sort === 'new' ? 'price' : 'new', page: 1 });
}}
type="button"
>
{sort === 'new' ? 'Sort by price' : 'Sort by newest'}
</button>
<ul>
{visible.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
<button
disabled={page === 1}
onClick={() => {
update({ page: page - 1 }, { history: 'push' });
}}
type="button"
>
Previous
</button>
<button
onClick={() => {
update({ page: page + 1 }, { history: 'push' });
}}
type="button"
>
Next
</button>
</section>
);
}Sorting refines the current entry with the default replace; paging passes { history: 'push' } so the back button walks back one page at a time.
Put it on a page
A page under @k8ordo/static or @k8ordo/server is a Server Component. Build links with href: a field you leave out means its default, and fields at their default are left out of the query, so the same state always makes the same, shortest URL.
// src/routes/products/page.tsx
import { products } from '../../data/products';
import { productListState } from '../../state/products';
import { ProductList } from './_parts/product-list';
export default function ProductsPage() {
return (
<>
<nav>
<a href={productListState.href('/products')}>All products</a>
<a href={productListState.href('/products', { sort: 'price' })}>
Cheapest first
</a>
</nav>
<ProductList products={products} />
</>
);
}Pages under the framework never receive the search — they get params and pathname, plus request under @k8ordo/server. The server render uses the url slot’s defaults, and the live URL takes over one render after hydration, which is why the filtering above happens inside the client component.
Read it on the server
Under a router that hands a page its search params — the Next.js App Router, for example — parseUrl reads them typed. Missing params get their defaults, a value the schema rejects falls back to that field’s own default, and reading never throws.
// src/app/products/page.tsx
import { products } from '../../data/products';
import { productListState } from '../../state/products';
import { ProductList } from './product-list';
type Props = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export default async function ProductsPage({ searchParams }: Props) {
const url = productListState.parseUrl(await searchParams);
return <ProductList initialUrl={url} products={products} />;
}- For example,
?q=shoes&page=0reads as{ q: 'shoes', page: 1, sort: 'new' }. - Pass what you read down as
initialUrland on touseAppState(productListState, { initialUrl })insideProductList, as below, and the server render and the hydration render show the real values instead of flashing the defaults.
// src/app/products/product-list.tsx
'use client';
import { useAppState } from '@k8ordo/state';
import type { OutputOf } from '@k8ordo/state';
import type { Product } from '../../data/products';
import { productListState } from '../../state/products';
const PAGE_SIZE = 20;
type Props = {
products: readonly Product[];
initialUrl: OutputOf<typeof productListState.url>;
};
export function ProductList({ products, initialUrl }: Props) {
const [{ q, page, sort }, update] = useAppState(productListState, {
initialUrl,
});
const visible = products
.filter((product) => product.name.includes(q))
.toSorted((a, b) =>
sort === 'price' ? a.price - b.price : b.createdAt - a.createdAt,
)
.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return (
<section>
<button
onClick={() => {
update({ sort: sort === 'new' ? 'price' : 'new', page: 1 });
}}
type="button"
>
{sort === 'new' ? 'Sort by price' : 'Sort by newest'}
</button>
<ul>
{visible.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
<button
disabled={page === 1}
onClick={() => {
update({ page: page - 1 }, { history: 'push' });
}}
type="button"
>
Previous
</button>
<button
onClick={() => {
update({ page: page + 1 }, { history: 'push' });
}}
type="button"
>
Next
</button>
</section>
);
}What the router must do
An update() that changes the URL calls navigation.navigate(). For that to be a state change rather than a page load, a router has to intercept the Navigation API’s navigate event. @k8ordo/router is one, and pages under @k8ordo/static and @k8ordo/server run on it: a navigation that keeps the pathname is handled as a state change, not a page change — nothing remounts, and neither scroll nor focus moves.
Everything else on the client works under any router: href links, GET forms, and updates that change only entry fields, localStorage or memory.