@k8ordo/state

組み合わせ

@k8ordo/state がルーターに求めることと、k8ordo のほかのパッケージやテストとの組み合わせ方です。

ルーター

ルーターの性質に左右される操作は 2 つです。URL を変える update() と、サーバーでの parseUrl です。それ以外はどのルーターでも動きます。

操作必要なもの
hrefsearch のリンク、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/router のナビゲーション

@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 の状態は同じスキーマです。formFieldsurl スロットのスキーマをそのまま渡します。

// 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 されている colorSchemeStatek8ordo-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 に同じキーを付けないでください。

@k8ordo/color-scheme の仕組み

テスト

parseUrlhrefsearch は純粋な関数なので、ブラウザ無しでそのまま確かめられます。

// 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']);
});