@k8ordo/state

読み取りとリンク

ページに search を渡すルーターの下では、サーバーが url スロットを parseUrl で読みます。リンクは定義から組み立てます。localStorage の値も、ハイドレーションより前に読めます。

parseUrl

parseUrl(input)url スロットを読み、スキーマの出力型の値を返します。inputURLSearchParams か、フレームワークがページに渡すオブジェクトの形(Record<string, string | string[] | undefined>、型は UrlInput)です。宣言していないパラメータは無視されます。

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

上の定義で、クエリ文字列は次のように読まれます。

クエリ`parseUrl` の結果理由
(なし){ q: '', page: 1, tags: [], sort: 'new' }すべてのフィールドが既定値
?q=shoes&page=3{ q: 'shoes', page: 3, tags: [], sort: 'new' }"3"z.coerce.number()3 になる
?q=shoes&page=zero{ q: 'shoes', page: 1, tags: [], sort: 'new' }読めない page だけが既定値に戻る
?q=shoes&page=0{ q: 'shoes', page: 1, tags: [], sort: 'new' }制約(z.gte(1))の違反も同じ扱い
?page=2.5{ q: '', page: 1, tags: [], sort: 'new' }z.int() の違反
?tags=sale&tags=new{ q: '', page: 1, tags: ['sale', 'new'], sort: 'new' }繰り返したパラメータが配列に集まる
?q=red&q=blue{ q: 'red', page: 1, tags: [], sort: 'new' }配列でないフィールドは最初の値
?sort=old&q=shoes{ q: 'shoes', page: 1, tags: [], sort: 'new' }列挙に無い値は既定値に戻る
?utm_source=news&page=2{ q: '', page: 2, tags: [], sort: 'new' }宣言していないパラメータは無視

オブジェクトの形でも同じです。parseUrl({ q: 'shoes', tags: ['sale', 'new'] }){ q: 'shoes', page: 1, tags: ['sale', 'new'], sort: 'new' } を返します。戻り値の型は { q: string; page: number; tags: string[]; sort: 'new' | 'price' } です。

フィールド単位のサルベージ

まずスキーマ全体で解析し、失敗したときだけフィールドごとに解析し直します。受け付けられないフィールドは自分の既定値に戻り、ほかのフィールドは読めた値を保ちます。1 つの壊れた値がほかを巻き込むことはなく、読み取りが throw することもありません。

配列は 1 つのフィールドです。要素が 1 つでも受け付けられなければ、配列全体が既定値の [] に戻ります。

フィールドごとの解析には、オブジェクト全体への refine が見えません。そこでサルベージした組み合わせを最後にスキーマ全体で確かめ、refine が拒むなら全体を既定値に戻します。

// 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)),
});
クエリ`parseUrl` の結果理由
?min=200&max=500{ min: 200, max: 500 }書かれたとおり
?min=200&max=abc{ min: 200, max: 1000 }max だけが既定値に戻り、組み合わせも成り立つ
?min=2000&max=abc{ min: 0, max: 1000 }max を既定値に戻すと min <= max が崩れるので、全体が既定値
?min=500&max=200{ min: 0, max: 1000 }どちらのフィールドも正しいが、組み合わせが refine に反する
?min=-5&max=300{ min: 0, max: 300 }min だけが既定値に戻る

同じサルベージは、エントリ状態・localStorage の行・definePageStatedefineLocalStateupdate() に渡した値にも適用されます。

@k8ordo/static@k8ordo/server のページ

このフレームワークのページは search params を受け取りません。受け取るのは paramspathname@k8ordo/server ではさらに、ヘッダーと Cookie を持つ request)です。pathname はルーターのもので、search は useAppState がブラウザで読みます。

サーバーの描画は url スロットの既定値で行われ、ハイドレーションの次の描画から実際の URL が使われます。これは回避すべき欠落ではありません。ルーターは pathname が変わらない遷移を何も読み込まずに intercept するので、search に依存したサーバーの描画は、最初の読み込みでは正しくても、最初の update() の後には古くなります。

hrefsearch は純粋な関数なので、この制約を受けず、Server Component でもそのまま使えます。

読んだ値を最初の描画に渡す

ページに search を渡すルーター(Next.js の App Router など)では、parseUrl の結果をクライアントコンポーネントに渡し、useAppStateinitialUrl にします。サーバーの描画とハイドレーションの描画が実際の URL の値で行われ、既定値からのちらつきが出ません。

// 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>
  );
}
  • initialUrl を受け取れるのは url スロットを持つ definePageState だけです。エントリの値はサーバーに存在しないので、常に既定値から始まります。props の型は OutputOf<typeof catalogState.url> で書けます。
  • Navigation API を intercept しないルーターでは、URL を書き換える update() はドキュメントの読み込みになります。そこでの URL の変更は、リンクと GET フォームで行うのが向いています。 ルーターとの組み合わせ

hrefsearch

href(base, values?) はリンクを組み立てます。指定しなかったフィールドは既定値として扱われ、既定値のフィールドはクエリから省かれます。同じ状態からはいつも同じ最短の URL ができるので、リンク・ブックマーク・キャッシュが一致します。

呼び出し結果
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()空文字列
  • パラメータはスキーマで宣言した順に並び、値は URLSearchParams の規則でエンコードされます(空白は +)。
  • URL に書けない値(Date など)を渡すと、hrefsearch は throw します。
  • 戻り値の型にはパスのリテラルが残るので、型付きルートの検査がクエリを取り除いてパスを確かめられます。
  • entry だけの定義では、hrefbase をそのまま返し、search は空文字列を返します。

指定しないフィールドは既定値になるので、href('/catalog', { page: 2 }) は今の検索語を落とします。一部だけを変えて残りを保つリンクは、今の状態を展開してから上書きします。

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

@k8ordo/router の下では素の <a> がクライアント遷移なので、このリンクも pathname の変わらない状態の変更として処理されます。リンクのクリックは push です。

search(values?) はクエリ文字列だけ(? なし)を返します。パスを自分で組み立てるとき、たとえばルート表に無いファイルのダウンロードに同じ条件を付けるときに使います。

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

型付きルート

Register を一度だけ拡張すると、アプリの中のすべての href が、ルーターの知らないパスを拒むようになります。@k8ordo/router の拡張と同じ 1 行です。

// 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;
  }
}
  • :param の区間には任意の文字列が入るので、/products/:id には /products/42 を渡せます。
  • * のワイルドカードは照合には使われますが、リンク先にはなりません。
  • パスの型は @k8ordo/routerRouteOf から型だけで導かれるので、ルーターは任意の peer のままで、実行時には読み込まれません。

@k8ordo/static@k8ordo/server では、アプリ自身の package.jsondependenciesdevDependencies@k8ordo/state があれば、この拡張が routes/ から .k8ordo/register.gen.ts に生成されます(推移的な依存は数えません)。生成された宣言と重なるので、そこでは手書きしないでください。

表を持たないルーターでは、そのルーターのパスの union を path に登録します。Next.js なら nextRoute です。

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

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

両方があれば routes が優先され、どちらも無ければ / で始まる任意の文字列が通ります。解決されたパスの型は RegisteredPath として export されています。拡張はアプリケーションでだけ行ってください。共有ライブラリが拡張すると、その制約がすべての利用者に漏れます。

ハイドレーション前に読む

最初の描画より前に要る値があります。<html> に付ける表示密度の属性や、既定値で一瞬表示されてはいけないカラースキームです。useAppState はハイドレーションの後に動くので間に合わず、かといってインラインスクリプトにキーと JSON の形を文字列で手書きすると、どちらかが変わった時点でずれます。

defineLocalState の定義はその両方を持っています。storageKey はストアが書き込むキーで、inlineRead() はインラインの <script> に埋め込む JavaScript の式を返します。この式は、ブラウザで保存されたオブジェクトに評価されます。

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

次のときは throw せず、null になります。

  • 何も保存されていない
  • JSON が壊れている
  • 値がオブジェクトではない(数値・文字列・配列・null
  • ストレージ自体が読めない

そこではまだどのモジュールも読み込まれていないので、スキーマは走りません。返るのはサルベージ済みの状態ではなく、保存された生の行です。信頼せず、必要なフィールドだけを、それぞれフォールバック付きで読んでください。上の例が density"compact" かどうかだけを確かめているのはそのためです。

キーは < も含めてスクリプトの文脈向けにエスケープされるので、どんなキーでも安全に埋め込めます。式は即時実行関数なので、代入の右辺・引数・三項演算子など、どの位置にも置けます。

ハイドレーションの後は、ストアを正とします。スクリプトは React より先に <html> の属性を変えるので、<html> には suppressHydrationWarning を付けます。その属性をハイドレーションの描画で消さずに保ち続ける書き方は、@k8ordo/color-scheme の実装がそのまま例になります。 @k8ordo/color-scheme の仕組み