@k8ordo/state

置き場所

定義はそれぞれ 1 つの置き場所を名指しします。値がいつまで残るか、誰に見えるか、サーバーから読めるかを決めるのは置き場所です。このページでは 4 つの置き場所の違いと、置き場所ごとのスキーマの書き方を説明します。

4 つの置き場所

urlentry は同じ履歴エントリの 2 つの面(見えて共有できる面と、隠れた面)なので、1 つの定義にまとまり、原子的に更新されます。localStorage とメモリはページではなくアプリ全体に属するので、別の種類の定義になっています。

定義置き場所残る期間共有される範囲サーバー
definePageState · urlsearch params戻る・進む、リンクの共有URL を受け取った人parseUrl で読める(search を渡すルーターのとき)
definePageState · entry履歴エントリの state戻る・進む、リロードそのタブのそのエントリ既定値で描画
defineLocalStatelocalStorage消されるまで同じブラウザで開いたサイトのすべてのタブ既定値で描画
defineMemoryStateJavaScript の実行環境リロードまでそのタブ初期値で描画

どれを選ぶか

  • URL — リンクで開いたときに再現されるべきもの。検索語、絞り込み、ページ番号、選択中のタブ。サーバーが描画に使う値もここに置きます。
  • エントリ — 戻る・進むで元に戻ってほしいが、共有するリンクには載せたくないもの。開いている行、詳細表示の有無など、そのページを見ている間だけの UI の状態。
  • localStorage — その端末を使う人の好み。表示形式、1 ページの件数、カラースキーム。
  • メモリ — 離れたコンポーネントどうしで共有したいが、リロードで消えてよいもの。コマンドパレットの開閉、デバッグ用のパネル。

2 つの面を触って確かめる

下のデモは本物の definePageState で、scope を URL に、開いている行を履歴エントリに置いています。

scope
URL
クエリなし(既定値)
entry
{"state-places-demo":{"open":[]}}

行を開いても URL は変わりません(updateCurrentEntry で現在のエントリに書かれます)。scope を切り替えると push で新しいエントリが作られ、開いていた行もそのエントリに持ち越されます。ブラウザの戻るで、scope と開いていた行が一緒に戻ります。リロードしても両方残りますが、URL を新しいタブに貼ると残るのは scope だけです。

url スロット

search params に置く状態です。URL は文字列しか運ばないので、スキーマは文字列から自分の型を読み出せる書き方にします。

// 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'),
  }),
});
  • 数値は z.coerce.number()?page=2"2"2 になります。
  • 真偽値は z.stringbool()"false"false として読み、false"false" と書きます。
  • 配列は同じ名前のパラメータの繰り返し(?tags=sale&tags=new)で、既定値は [] だけが許されます。
  • 配列でないフィールドのパラメータが繰り返されたら、最初の値を読みます。
  • URL に書ける値は、文字列・数値・bigint・真偽値と、それらの配列です。
  • 既定値のフィールドはクエリに書かれません。誰かが ?page=1 と書いても、url のフィールドを変える次の update() で省かれます。

真偽値・配列の列挙値・日付を持つ定義は、たとえば次のように書きます。

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

export const mapState = definePageState('map', {
  url: z.object({
    zoom: z._default(z.coerce.number().check(z.int(), z.gte(1), z.lte(20)), 12),
    satellite: z._default(z.stringbool(), false),
    layers: z._default(z.array(z.enum(['traffic', 'transit'])), []),
    since: z.optional(z.iso.date()),
  }),
});

拒まれる書き方

update() は書いた値を、URL から戻ってくるのと同じ道(クエリ文字列に書いて読み直す)で確かめます。自分が書いたクエリ文字列を読み戻せないフィールドは、書き込むたびに既定値に落ちます。そうなることが確実な書き方は、最初のクリックを待たずにモジュールの読み込み時に拒まれます。

書いたもの代わりに理由
z.boolean() / z.coerce.boolean()z.stringbool()URL は文字列を運び、"false"z.boolean() にとって false ではありません(z.coerce.boolean() では true になります)
既定値が [] でない配列、z.optional() の配列z._default(z.array(…), [])パラメータが無いことと空の配列は同じ URL なので、既定値が [] でなければ [] を書けません
z._default()z.optional() も無いフィールドz._default() / z.optional()パラメータはいつでも欠けえます。url に限らず、スキーマを持つすべての置き場所の規則です
z.date()z.iso.date() などの文字列のフィールドURL に綴りがありません。これだけは定義時ではなく、値を書こうとしたとき(hrefsearchupdate())に throw します

entry スロット

履歴エントリに付く隠れた状態です。URL には出ず、Navigation API のエントリ状態(navigation.currentEntry.getState())の中に、定義のキーを名前空間として保存されます。

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

export const orderPanelState = definePageState('order-panel', {
  entry: z.object({
    expanded: z._default(z.array(z.string()), []),
    showTotals: z._default(z.boolean(), false),
  }),
});
  • 値は文字列にされず、エントリにそのまま保存されるので、z.number()z.boolean() を書いたとおりに使えます。url スロットのような書き方の制限はありません。ただし、スキーマは自分の出力を入力として受け付けなければなりません(下の「スキーマの規則」)。
  • エントリの値だけを変える update() は遷移を起こさず、navigation.updateCurrentEntry() で現在のエントリを書き換えるので、どのルーターの下でも動きます。その代わり新しい履歴エントリは作られず、{ history: 'push' } を渡しても無視されます。戻るボタンで 1 段ずつ戻したい状態(ウィザードの手順など)は url に置きます。
  • サーバーにはエントリ状態が存在しないので、サーバーの描画とハイドレーションの描画は既定値で行われます。
  • セッション復元で戻ってきた、古いスキーマが書いた値も入力として扱われ、受け付けられないフィールドは既定値に戻ります。

urlentry を 1 つの定義に

1 つの定義に両方を書くと、useAppState が返す状態は 2 つのスロットを平らにマージしたものになります。フィールドをスロット間で移しても、変わるのは定義だけで、呼び出し側は変わりません。

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

export const ordersState = definePageState('orders', {
  url: z.object({
    status: z._default(z.enum(['open', 'shipped']), 'open'),
  }),
  entry: z.object({
    expanded: z._default(z.array(z.string()), []),
  }),
});
// src/orders/order-tabs.tsx
'use client';

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

import { ordersState } from '../state/orders';

export function OrderTabs() {
  const [{ status }, update] = useAppState(ordersState, ['status']);

  return (
    <button
      onClick={() => {
        update(
          { status: status === 'open' ? 'shipped' : 'open', expanded: [] },
          { history: 'push' },
        );
      }}
      type="button"
    >
      {status === 'open' ? 'Show shipped' : 'Show open'}
    </button>
  );
}
  • 両方のスロットにまたがる更新は、1 回の navigation.navigate() に URL とエントリ状態をまとめて渡すので、片方だけが反映された状態は見えません。戻るボタンでも両方が一緒に戻ります。
  • update() が URL を書き換える遷移は、そのときのエントリ状態を新しいエントリに持ち越します。ほかの定義の名前空間など、エントリ状態にあるほかの値も残ります。
  • 同じ名前のフィールドを両方に書くと型エラーになり(メッセージにフィールド名が出ます)、実行時にも throw します。どちらのスロットも持たない定義も同様です。
  • ただし、スロットごとの規則はフィールドに付いて回ります。entryz.boolean() だったフィールドを url に移すなら z.stringbool() に、url から entry に移すなら z.boolean() に書き換えます。entry は型付きの値をそのままスキーマに戻すので、z.stringbool() のままでは update() のたびに既定値に戻ります。

defineLocalState

localStorage に置く、アプリ全体の状態です。同じブラウザのタブ間で共有され、消されるまで残ります。

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

export const prefsState = defineLocalState(
  'prefs',
  z.object({
    view: z._default(z.enum(['grid', 'table']), 'grid'),
    pageSize: z._default(z.number().check(z.int(), z.gte(10), z.lte(100)), 20),
  }),
);
  • 値は k8ordo-state:<key>(定義の storageKey)の 1 行に、スキーマが宣言したフィールドだけの JSON として保存されます。上の定義なら、k8ordo-state:prefs{"view":"grid","pageSize":20} のような行です。
  • 保存は JSON を通るので、フィールドは JSON で表せる型にします。z.date() の値は書き込みの直後には表示されますが、次に読み込んだときには文字列になっていて、既定値に戻ります。entry と同じく、スキーマは自分の出力を入力として受け付けなければなりません。
  • ほかのタブの書き込みは storage イベントで届き、変わったキーを購読しているコンポーネントだけが再描画されます。
  • 古いスキーマが書いた行はフィールドごとにサルベージされ、壊れた JSON は既定値から始まります。
  • サーバーには localStorage が無いので、サーバーの描画とハイドレーションの描画は既定値です。最初の描画より前に値が要るなら、ハイドレーションの前に読みます。 ハイドレーション前に読む

defineMemoryState

JavaScript の実行環境に置く、型付きの共有の箱です。そのタブの中だけで共有され、リロードで初期値に戻ります。

// src/state/command-palette.ts
import { defineMemoryState } from '@k8ordo/state';

export const commandPaletteState = defineMemoryState<{
  open: boolean;
  query: string;
  scope: 'all' | 'pages' | 'actions';
}>('command-palette', { open: false, query: '', scope: 'all' });
// src/command-palette/palette-button.tsx
'use client';

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

import { commandPaletteState } from '../state/command-palette';

export function PaletteButton() {
  const [{ open }, update] = useAppState(commandPaletteState, ['open']);

  return (
    <button
      aria-expanded={open}
      onClick={() => {
        update({ open: !open, query: '' });
      }}
      type="button"
    >
      Commands
    </button>
  );
}
  • スキーマを持たない唯一の種類です。値が境界を越えて戻ってくることがなく、型付きの update() だけが書き手なので、検証し直すものがありません。型は初期値から推論されます。ユニオン型のように初期値から推論できない型は、上の例のように型引数で書きます。
  • 値は不変として扱ってください。変更の検出は update() に渡されたフィールドを前の値と比べて行うので、ネストしたオブジェクトをその場で書き換えても誰にも通知されません。
  • フィールドは初期値のキーで固定されます。update() はすぐに反映され、まとめられることはありません。サーバーの描画は初期値で行われます。

キーは識別子

定義の第 1 引数は、その状態の識別子です。

  • ブラウザのストアはこの文字列で登録されます。定義オブジェクトではなく文字列で引くので、HMR でモジュールが評価し直されても、同じ状態につながります。
  • definePageState では、エントリ状態の中の名前空間です。
  • defineLocalState では、localStorage のキー k8ordo-state:<key> になります。

キーを変えると、保存されたデータの名前も変わります。同じ種類の定義が同じキーを使うと、1 つのストア(local なら 1 つの行)を黙って共有します。モジュールシステムはこれを検出できないので、アプリ全体のグローバル名として扱ってください。

スキーマの規則

スキーマが要るのは、データが境界を越えて戻ってくる場所だけです。利用者が書き換えられる URL、古いスキーマが書いた localStorage、セッション復元で戻ってきたエントリ状態。そこから来る値は、信頼済みの状態ではなく入力として扱われます。

  • スキーマは z.object() です。zodzod/mini のどちらで書いてもかまいません。
  • どのフィールドも、欠けたまま読めなければなりません。z._default().default())も z.optional() も無いフィールドは、定義時にフィールド名付きで throw します。z.optional() のフィールドの既定値は undefined です。
  • オブジェクト全体への refine は、すべてのフィールドが既定値の状態を受け付けなければなりません。受け付けないと定義時に throw します。
  • entry と localStorage のスキーマは、自分の出力をそのまま入力として受け付けなければなりません。保存された値は型付きのまま戻ってきてスキーマを通り直すので、z.stringbool() や型を変える変換は、書き込むたびに既定値に戻ります。url では値がクエリ文字列を通って戻るので、z.stringbool() が使えます。
  • スキーマが受け付けない値はそのフィールドだけが既定値に戻り、読み取りが throw することはありません。 サルベージの具体例

zodzod/mini

解析は zod の共通のコアで行うので、どちらの入口で書いたスキーマでも動きます。クライアントはスキーマそのもので解析と書き出しを行い、スキーマを持つモジュールはブラウザにも届きます。アプリがすでに classic の zod を読み込んでいるのでなければ、zod/mini を選んでください。スキーマがサーバーに留まる @k8ordo/form とは、ここが違います。

同じ定義を両方の書き方で並べると、次のようになります。

// 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'),
  }),
});
// src/state/catalog.ts
import { definePageState } from '@k8ordo/state';
import * as z from 'zod';

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

定義の中身

定義は、スキーマ(メモリなら初期値)と純粋な関数だけのオブジェクトです。型はすべて @k8ordo/state から export されています。

中身
PageStatekind: 'page'keyurlentryparseUrlhrefsearchurlentry は渡したスキーマそのもの(書かなかった方は undefined
LocalStatekind: 'local'keyschemastorageKeyinlineRead
MemoryStatekind: 'memory'keyinitialinitial は渡した初期値の浅いコピー
StateSchemaurlentrydefineLocalState が受け取るスキーマの型。zodzod/miniz.object() に共通する部分です
OutputOfスキーマの出力型(undefined なら空のオブジェクト型)。props の型に OutputOf<typeof catalogState.url> のように使います