@k8ordo/state

Reading & links

Under a router that hands a page its search, the server reads the url slot with parseUrl, and links are built from the definition. localStorage values can be read before hydration, too.

parseUrl

parseUrl(input) reads the url slot and returns a value of the schema’s output type. input is a URLSearchParams or the object shape frameworks hand a page (Record<string, string | string[] | undefined>, typed as UrlInput). Params the schema does not declare are ignored.

// src/state/catalog.ts
import { definePageState } from '@k8ordo/state';
import * as z from 'zod/mini';

export const catalogState = definePageState('catalog', {
  url: z.object({
    q: z._default(z.string(), ''),
    page: z._default(z.coerce.number().check(z.int(), z.gte(1)), 1),
    tags: z._default(z.array(z.string()), []),
    sort: z._default(z.enum(['new', 'price']), 'new'),
  }),
});

With the definition above, query strings read like this:

Query`parseUrl` returnsWhy
(none){ q: '', page: 1, tags: [], sort: 'new' }Every field at its default
?q=shoes&page=3{ q: 'shoes', page: 3, tags: [], sort: 'new' }"3" is coerced to 3
?q=shoes&page=zero{ q: 'shoes', page: 1, tags: [], sort: 'new' }Only the unreadable page falls back
?q=shoes&page=0{ q: 'shoes', page: 1, tags: [], sort: 'new' }A constraint violation (z.gte(1)) is treated the same
?page=2.5{ q: '', page: 1, tags: [], sort: 'new' }Fails z.int()
?tags=sale&tags=new{ q: '', page: 1, tags: ['sale', 'new'], sort: 'new' }Repeated params collect into the array
?q=red&q=blue{ q: 'red', page: 1, tags: [], sort: 'new' }A non-array field takes the first value
?sort=old&q=shoes{ q: 'shoes', page: 1, tags: [], sort: 'new' }A value outside the enum falls back
?utm_source=news&page=2{ q: '', page: 2, tags: [], sort: 'new' }Undeclared params are ignored

The object shape reads the same way: parseUrl({ q: 'shoes', tags: ['sale', 'new'] }) returns { q: 'shoes', page: 1, tags: ['sale', 'new'], sort: 'new' }, typed as { q: string; page: number; tags: string[]; sort: 'new' | 'price' }.

Salvage, field by field

The whole schema parses first; only when that fails does each field parse on its own. A field the schema rejects falls back to its own default and the others keep what they read, so one broken value does not take the rest down, and reading never throws.

An array is one field: if any element is rejected, the whole array falls back to its default [].

Per-field parsing cannot see an object-level refine. So the salvaged combination is checked against the whole schema at the end, and if the refine rejects it, everything falls back to the defaults.

// src/state/price.ts
import { definePageState } from '@k8ordo/state';
import * as z from 'zod/mini';

export const priceState = definePageState('price-filter', {
  url: z
    .object({
      min: z._default(z.coerce.number().check(z.gte(0)), 0),
      max: z._default(z.coerce.number().check(z.gte(0)), 1000),
    })
    .check(z.refine((range) => range.min <= range.max)),
});
Query`parseUrl` returnsWhy
?min=200&max=500{ min: 200, max: 500 }As written
?min=200&max=abc{ min: 200, max: 1000 }max falls back alone, and the combination still holds
?min=2000&max=abc{ min: 0, max: 1000 }With max back at its default, min <= max fails, so everything falls back
?min=500&max=200{ min: 0, max: 1000 }Each field is valid; the combination is not
?min=-5&max=300{ min: 0, max: 300 }Only min falls back

The same salvage applies to entry state, to localStorage rows, and to the values passed to update() on definePageState and defineLocalState.

Pages under @k8ordo/static and @k8ordo/server

Pages under the framework never see the search. They receive params and pathname — plus a request with headers and cookies under @k8ordo/server. The pathname is the router’s, and the search is read in the browser by useAppState.

The server render uses the url slot’s defaults, and the live URL takes over one render after hydration. That is not a gap to work around: the router intercepts a navigation that keeps the pathname without loading anything, so a server render keyed on the search would be right on the first load and stale after the first update().

href and search are pure functions, so none of this affects them; they run in a Server Component as they are.

Seeding the first render

Under a router that hands a page its search — the Next.js App Router, for example — pass what parseUrl returned down to the client component and give it to useAppState as initialUrl. The server render and the hydration render then show the real URL values instead of flashing the defaults.

// src/app/catalog/page.tsx
import { catalogState } from '../../state/catalog';
import { CatalogFilters } from './catalog-filters';

type Props = {
  searchParams: Promise<Record<string, string | string[] | undefined>>;
};

export default async function CatalogPage({ searchParams }: Props) {
  const url = catalogState.parseUrl(await searchParams);

  return (
    <>
      <CatalogFilters initialUrl={url} />
      <a href={catalogState.href('/catalog', { ...url, page: url.page + 1 })}>
        Next page
      </a>
    </>
  );
}
// src/app/catalog/catalog-filters.tsx
'use client';

import { useAppState } from '@k8ordo/state';
import type { OutputOf } from '@k8ordo/state';

import { catalogState } from '../../state/catalog';

type Props = {
  initialUrl: OutputOf<typeof catalogState.url>;
};

export function CatalogFilters({ initialUrl }: Props) {
  const [{ sort }, update] = useAppState(catalogState, ['sort'], {
    initialUrl,
  });

  return (
    <select
      onChange={(event) => {
        update({
          sort: event.currentTarget.value === 'price' ? 'price' : 'new',
          page: 1,
        });
      }}
      value={sort}
    >
      <option value="new">Newest</option>
      <option value="price">Price</option>
    </select>
  );
}
  • Only a definePageState with a url slot accepts initialUrl. The entry slot has no server-side source and always starts from its defaults. The prop’s type is OutputOf<typeof catalogState.url>.
  • On a router that does not intercept the Navigation API, an update() that changes the URL is a full document load; there, links and GET forms are the better way to change the URL. Working with routers

href and search

href(base, values?) builds a link. A field you leave out means its default, and fields at their default are left out of the query, so the same state always yields the same, shortest URL — links, bookmarks and caches agree.

CallReturns
catalogState.href('/catalog')/catalog
catalogState.href('/catalog', { page: 1 })/catalog
catalogState.href('/catalog', { page: 2 })/catalog?page=2
catalogState.href('/catalog', { q: 'red shoes', tags: ['sale', 'new'] })/catalog?q=red+shoes&tags=sale&tags=new
catalogState.href('/catalog', { sort: 'price', page: 3 })/catalog?page=3&sort=price
catalogState.search({ q: 'red shoes', page: 2 })q=red+shoes&page=2
catalogState.search()An empty string
  • Params follow the order the schema declares them in and are encoded by URLSearchParams rules (a space becomes +).
  • Given a value a URL cannot hold, such as a Date, href and search throw.
  • The return type keeps the path literal, which is what lets a typed-route check strip the query and verify the path.
  • For an entry-only definition, href returns base unchanged and search returns an empty string.

Because a field you leave out means its default, href('/catalog', { page: 2 }) drops the current search term. A link that changes one field and keeps the rest spreads the current state first.

// src/catalog/pager.tsx
'use client';

import { useAppState } from '@k8ordo/state';

import { catalogState } from '../state/catalog';

export function Pager() {
  const [state] = useAppState(catalogState);

  return (
    <nav>
      {state.page > 1 && (
        <a
          href={catalogState.href('/catalog', {
            ...state,
            page: state.page - 1,
          })}
        >
          Previous
        </a>
      )}
      <a href={catalogState.href('/catalog', { ...state, page: state.page + 1 })}>
        Next
      </a>
    </nav>
  );
}

Under @k8ordo/router a plain <a> is a client navigation, so this link too is handled as a state change that keeps the pathname. A link click pushes.

search(values?) returns the query string alone, with no ?. Use it when the path is yours to compose — a file download outside the route table, for instance.

// src/catalog/export-link.tsx
'use client';

import { useAppState } from '@k8ordo/state';

import { catalogState } from '../state/catalog';

export function ExportLink() {
  const [state] = useAppState(catalogState);
  const query = catalogState.search(state);

  return (
    <a
      download
      href={query === '' ? '/export/catalog.csv' : `/export/catalog.csv?${query}`}
    >
      Download CSV
    </a>
  );
}

Typed routes

Augment Register once and every href in the app rejects a path its router does not know. It is the same line as the @k8ordo/router augmentation.

// src/k8ordo.d.ts
import type { routes } from './routes';

declare module '@k8ordo/router' {
  interface Register {
    routes: typeof routes;
  }
}

declare module '@k8ordo/state' {
  interface Register {
    routes: typeof routes;
  }
}
  • A :param segment takes any string, so /products/:id accepts /products/42.
  • A * wildcard is matched, never linked.
  • The path union comes from RouteOf in @k8ordo/router as a type only, so the router stays an optional peer and is never loaded at runtime.

Under @k8ordo/static and @k8ordo/server this augmentation is generated into .k8ordo/register.gen.ts from routes/ when the application’s own package.json lists @k8ordo/state in dependencies or devDependencies — a transitive dependency does not count. Do not hand-write it there: it would duplicate the generated declaration.

A router without a table registers its own path union under pathRoute from next, for instance.

// src/k8ordo-state.d.ts
import type { Route } from 'next';

declare module '@k8ordo/state' {
  interface Register {
    path: Route;
  }
}

When both are present, routes wins; with neither, any /-prefixed string is accepted. The resolved path type is exported as RegisteredPath. Augment only in an application — a shared library that augments Register leaks its constraint to every consumer.

Reading before hydration

Some values are needed before the first paint: a density attribute on <html>, say, or a colour scheme that must not flash its default. useAppState runs after hydration, which is too late, and an inline script with the storage key and the JSON shape hand-written into a string drifts the moment either changes.

A defineLocalState definition carries both halves. storageKey is the key the store writes under, and inlineRead() returns a JavaScript expression for an inline <script> that evaluates, in the browser, to the stored object.

// src/state/density.ts
import { defineLocalState } from '@k8ordo/state';
import * as z from 'zod/mini';

export const densityState = defineLocalState(
  'density',
  z.object({ density: z.optional(z.enum(['comfortable', 'compact'])) }),
);
// src/routes/layout.tsx
import type { ReactNode } from 'react';

import { densityState } from '../state/density';

const densityScript = `(()=>{const s=${densityState.inlineRead()};if(s&&s.density==="compact")document.documentElement.dataset.density="compact"})()`;

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <script>{densityScript}</script>
        {children}
      </body>
    </html>
  );
}

It evaluates to null, without throwing, when:

  • nothing is stored
  • the JSON is corrupt
  • the value is not an object (a number, a string, an array, null)
  • storage itself cannot be read

No module has loaded yet, so the schema does not run: what comes back is the raw stored row, not the salvaged state useAppState will show. Treat it as untrusted and read only the fields you need, each with its own fallback — which is why the example checks nothing but whether density is "compact".

The key is escaped for a script context, < included, so any key is safe to emit. The expression is a self-invoking function, so it fits any position: the right-hand side of an assignment, an argument, a ternary.

After hydration the store is the source of truth. The script changes an attribute on <html> before React sees it, so <html> carries suppressHydrationWarning. @k8ordo/color-scheme is a worked example of keeping the attribute in step without the hydration render undoing what the script did. How @k8ordo/color-scheme works