Integrations
What @k8ordo/state needs from a router, and how it fits with the other k8ordo packages and with tests.
Routers
Two operations depend on the router: an update() that changes the URL, and parseUrl on the server. Everything else works under any router.
| Operation | Needs |
|---|---|
href / search links, GET forms | Nothing — the router handles the click or the submission |
Updates that change only entry, local or memory values | Nothing — no navigation is involved |
An update() that changes the URL | A router that intercepts the Navigation API |
parseUrl on the server | A router that hands the page its search |
An update() that changes the URL calls navigation.navigate(). Unless a router takes that navigate event with event.intercept(), it is a cross-document load. There is deliberately no History API fallback.
@k8ordo/router
Under @k8ordo/router a navigation that keeps the pathname is a state change, not a page change: the router intercepts it without loading anything, leaves the route tree alone, remounts nothing, and moves neither scroll nor focus. That is why changing a filter never sends the reader back to the top.
@k8ordo/router does not deal in search params: there is no useSearchParams, and it never hands out the search string. The boundary is the URL’s ? — the pathname is the router’s, everything after it belongs to @k8ordo/state.
Typed href paths take the same Register line as the router’s augmentation. Typed routes
@k8ordo/static and @k8ordo/server
Pages under the framework run on @k8ordo/router, so everything above holds. Two things differ: a page never receives the search, so url is read in the browser, and Register is generated into .k8ordo/register.gen.ts. Reading under the framework
Routers that do not intercept the Navigation API
On a router that does not intercept the Navigation API — Next.js today — an update() that changes the URL is a full document load. Change the URL through links and GET forms, read it on the page with parseUrl, and pass initialUrl down: the grain this package prefers anyway. Updates that change only entry fields, localStorage or memory involve no navigation and work as they are.
GET forms with @k8ordo/form
A search or filter form is a GET form, and its constraints and its URL state are the same schema: hand the url slot’s schema straight to formFields.
// src/state/filter.ts
import { definePageState } from '@k8ordo/state';
import * as z from 'zod/mini';
export const filterState = definePageState('product-filter', {
url: z.object({
q: z._default(z.string().check(z.maxLength(50)), ''),
min: z._default(z.coerce.number().check(z.int(), z.gte(0)), 0),
}),
});// src/routes/catalog/page.tsx
import { formFields } from '@k8ordo/form/server';
import { filterState } from '../../state/filter';
import { FilterForm } from './_parts/filter-form';
const filterFields = formFields(filterState.url);
export default function CatalogPage() {
return <FilterForm fields={filterFields} />;
}// src/routes/catalog/_parts/filter-form.tsx
'use client';
import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useAppState } from '@k8ordo/state';
import { filterState } from '../../../state/filter';
type Props = {
fields: FormFields<'q' | 'min', never>;
};
export function FilterForm({ fields }: Props) {
const form = useForm(fields);
const q = form.field('q');
const min = form.field('min');
const [{ q: currentQ, min: currentMin }] = useAppState(filterState);
return (
<form method="get" {...form.props}>
<label>
Keyword
<input {...q.input} defaultValue={currentQ} />
</label>
{q.error !== undefined && <p>{q.error}</p>}
<label>
Minimum price
<input {...min.input} defaultValue={currentMin} />
</label>
{min.error !== undefined && <p>{min.error}</p>}
<button type="submit">Filter</button>
</form>
);
}formFieldsruns in the Server Component and its result crosses to the client as JSON props, so@k8ordo/formsends no zod to the browser (the schemauseAppStateuses ships regardless). The form submits withmethod="get";@k8ordo/routerintercepts GET forms, so the submission rewrites the URL as a client navigation anduseAppStatereads the values back. With no Server Action behind it,useForm(fields)takes no state.- Before JavaScript loads, the submission still writes the URL correctly, and under a router that hands the page its search,
parseUrlrenders from it directly. Under@k8ordo/staticand@k8ordo/serverthe server render shows the defaults, so the submitted values appear once the page hydrates. - A GET form submits every named control, so right after a submission the URL carries fields at their defaults too (
?q=&min=0). The values read the same, and a link built withhref, or the nextupdate()that changes aurlvalue, writes the canonical form again.
This exact combination runs as the demo on this site’s @k8ordo/form page. The @k8ordo/form demo
@k8ordo/color-scheme
@k8ordo/color-scheme stores its preference through a defineLocalState of its own. The exported colorSchemeState is the k8ordo-state:color-scheme row, whose preference is 'light', 'dark', or absent — nothing chosen, so the provider’s defaultPreference applies ('system' unless set).
The inline script that puts the class on <html> before the first paint is built from colorSchemeState.inlineRead(): neither the storage key nor the code that parses the row is written by hand in color-scheme, and cross-tab sync and the salvage of an old row are @k8ordo/state’s.
The stored value reads like any other local state, through useAppState. Change it through setPreference from useColorScheme(): it turns 'system' into an absent value, and the provider is where the class on screen is decided.
// src/components/stored-preference.tsx
'use client';
import { colorSchemeState } from '@k8ordo/color-scheme';
import { useAppState } from '@k8ordo/state';
export function StoredPreference() {
const [{ preference }] = useAppState(colorSchemeState);
return <p>{preference ?? 'system'}</p>;
}The key color-scheme is taken by that package; do not give one of your own defineLocalState definitions the same key.
Testing
parseUrl, href and search are pure functions and can be asserted directly, with no browser.
// src/state/catalog.test.ts
import { describe, expect, it } from 'vitest';
import { catalogState } from './catalog';
describe('catalogState', () => {
it('falls back to the default for a page the schema rejects', () => {
expect(catalogState.parseUrl(new URLSearchParams('page=0')).page).toBe(1);
});
it('leaves defaults out of links', () => {
expect(catalogState.href('/catalog', { page: 1, tags: [] })).toBe(
'/catalog',
);
});
});Components that use useAppState run on the real Navigation API and localStorage, so test them in a browser environment — this package’s own suite uses Vitest browser mode.
resetStateRegistry()clears the provider-less store registry between tests; without it, one test’s state carries into the next.- Unmount components first: a mounted hook keeps its old store through closures.
- When testing URL updates, intercept the
navigateevent in the test itself, as a router would. An uninterceptednavigation.navigate()is a cross-document load that takes the test runner with it. - Clear localStorage rows by the definition’s
storageKey.
// src/catalog/tag-filter.browser.test.tsx
import { resetStateRegistry } from '@k8ordo/state';
import { afterEach, beforeEach, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-react';
import { prefsState } from '../state/prefs';
import { TagFilter } from './tag-filter';
const interceptAsRouter = (event: NavigateEvent) => {
if (event.canIntercept) event.intercept();
};
let home = '';
beforeEach(() => {
home = location.href;
navigation.addEventListener('navigate', interceptAsRouter);
});
afterEach(async () => {
await cleanup();
await navigation.navigate(home, { history: 'replace' }).finished;
navigation.removeEventListener('navigate', interceptAsRouter);
resetStateRegistry();
localStorage.removeItem(prefsState.storageKey);
});
it('writes the selected tag into the URL', async () => {
const screen = await render(<TagFilter tags={['sale', 'new']} />);
await screen.getByRole('button', { name: 'sale' }).click();
await expect
.poll(() => new URL(location.href).searchParams.getAll('tags'))
.toEqual(['sale']);
});