@k8ordo/state

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.

OperationNeeds
href / search links, GET formsNothing — the router handles the click or the submission
Updates that change only entry, local or memory valuesNothing — no navigation is involved
An update() that changes the URLA router that intercepts the Navigation API
parseUrl on the serverA 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

Navigation in @k8ordo/router

@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>
  );
}
  • formFields runs in the Server Component and its result crosses to the client as JSON props, so @k8ordo/form sends no zod to the browser (the schema useAppState uses ships regardless). The form submits with method="get"; @k8ordo/router intercepts GET forms, so the submission rewrites the URL as a client navigation and useAppState reads 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, parseUrl renders from it directly. Under @k8ordo/static and @k8ordo/server the 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 with href, or the next update() that changes a url value, 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.

How @k8ordo/color-scheme works

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 navigate event in the test itself, as a router would. An unintercepted navigation.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']);
});