ルーター
ルーターの性質に左右される操作は 2 つです。URL を変える update() と、サーバーでの parseUrl です。それ以外はどのルーターでも動きます。
| 操作 | 必要なもの |
|---|---|
href・search のリンク、GET フォーム | 何も要らない(クリックや送信はルーターが処理する) |
entry・local・memory の値だけを変える update() | 何も要らない(遷移を伴わない) |
URL を変える update() | Navigation API を intercept するルーター |
サーバーでの parseUrl | ページに search を渡すルーター |
URL を変える update() は navigation.navigate() を呼びます。ルーターがその navigate イベントを event.intercept() で受け止めなければ、それは別のドキュメントの読み込みになります。History API へのフォールバックは、意図して持っていません。
@k8ordo/router
@k8ordo/router の下では、pathname が変わらない遷移はページの切り替えではなく状態の変更です。ルーターは何も読み込まずに intercept し、ルート木に触れず、何も再マウントせず、スクロールもフォーカスも動かしません。フィルタを変えてもページの先頭に戻されないのはこのためです。
@k8ordo/router は search params を扱いません。useSearchParams は無く、search の文字列も渡しません。境界は URL の ? で、pathname はルーター、その後ろは @k8ordo/state の担当です。
href のパスの型付けには、ルーターの拡張と同じ Register の 1 行を使います。 型付きルート
@k8ordo/static と @k8ordo/server
このフレームワークのページは @k8ordo/router の上で動くので、上の性質はそのまま成り立ちます。違いは 2 つです。ページが search を受け取らないので url はブラウザで読まれること、そして Register が .k8ordo/register.gen.ts に生成されることです。 フレームワークのページでの読み取り
Navigation API を intercept しないルーター
今の Next.js のように Navigation API を intercept しないルーターでは、URL を変える update() はドキュメント全体の読み込みになります。URL の変更はリンクと GET フォームで行い、ページで parseUrl して initialUrl を渡してください。もともとこのパッケージが勧める粒度です。entry のフィールド・localStorage・メモリだけを変える update() は遷移を伴わないので、そのまま使えます。
@k8ordo/form と GET フォーム
検索や絞り込みのフォームは GET フォームで、その制約と URL の状態は同じスキーマです。formFields に url スロットのスキーマをそのまま渡します。
// 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は Server Component で実行され、結果は JSON として props で渡るので、@k8ordo/formの側から zod がブラウザに届くことはありません(useAppStateが使うスキーマは、それとは別に届きます)。フォームはmethod="get"で送信されます。@k8ordo/routerは GET フォームを intercept するので、送信はクライアント遷移として URL を書き換え、useAppStateがその値を読み返します。Server Action が無いので、useForm(fields)に状態は渡しません。- JavaScript が読み込まれる前でも、送信は URL を正しく書き換えます。ページに search を渡すルーターなら、
parseUrlでそのまま描画できます。@k8ordo/static・@k8ordo/serverではサーバーの描画が既定値なので、送信した値が画面に出るのはハイドレーションの後です。 - GET フォームは名前の付いたすべての入力を送るので、送信直後の URL には既定値のフィールドも残ります(
?q=&min=0)。読み取りの結果は変わらず、hrefで作ったリンクや、urlの値を変える次のupdate()で正規の形に戻ります。
この組み合わせの実物は、このサイトの @k8ordo/form のページにあるデモです。 @k8ordo/form のデモ
@k8ordo/color-scheme
@k8ordo/color-scheme の保存先は、それ自体が defineLocalState です。export されている colorSchemeState は k8ordo-state:color-scheme の行で、preference は 'light'・'dark'・未設定のいずれかです。未設定は何も選ばれていない状態で、provider の defaultPreference(指定しなければ 'system')が使われます。
最初の描画の前に <html> にクラスを付けるインラインスクリプトは、colorSchemeState.inlineRead() から組み立てられています。保存キーも、行を JSON として読む処理も color-scheme には手書きされておらず、タブ間の同期も古い行のサルベージも @k8ordo/state が担います。
保存された値は、ほかの localStorage の状態と同じように useAppState で読めます。変更は useColorScheme() の setPreference を通してください。'system' を未設定に置き換え、画面のクラスを決めているのは provider だからです。
// 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>;
}キー color-scheme はこのパッケージが使っているので、アプリの defineLocalState に同じキーを付けないでください。
テスト
parseUrl・href・search は純粋な関数なので、ブラウザ無しでそのまま確かめられます。
// 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',
);
});
});useAppState を使うコンポーネントは本物の Navigation API と localStorage の上で動くので、ブラウザ環境でテストします(このパッケージ自身は Vitest のブラウザモードを使っています)。
resetStateRegistry()は、Provider を持たないストアの登録表をテストの間で空にします。呼ばないと、前のテストの状態が次のテストに持ち越されます。- 先にコンポーネントをアンマウントしてください。マウントされたままのフックは、クロージャ越しに古いストアを持ち続けます。
- URL を書き換える更新をテストするときは、ルーターの代わりにテスト自身が
navigateイベントを intercept します。intercept されないnavigation.navigate()はドキュメントの読み込みになり、テストランナーごと移動してしまいます。 - localStorage の行は、定義の
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']);
});