@k8ordo/state

Get Started

@k8ordo/state は、状態を「どこに住むか」で宣言します。このページでは URL に置く状態を 1 つ定義し、インストールから、コンポーネントでの利用、サーバーでの読み取りまでを通して書きます。

考え方

状態を持つとき、ふつうは先にストアを選び、永続化や URL との同期はあとから足します。ここでは順番が逆で、最初に置き場所を決めます。置き場所が決まれば、寿命(いつ消えるか)と共有範囲(誰に見えるか)が決まります。境界を越えて値が戻ってくる置き場所(URL・履歴エントリ・localStorage)はスキーマを 1 つずつ持ち、そこからサーバーでの読み取り・正規化されたリンク・古いデータのサルベージ・キー単位の購読が導かれます。メモリは境界を越えないので、スキーマを持たない型付きの箱です。

  • definePageStateurl — URL の search params。リンクで共有でき、search を渡すルーターならサーバーで読めます。
  • definePageStateentry — 履歴エントリの隠れた状態。戻る・進むで復元されますが、URL には出ません。
  • defineLocalState — localStorage。同じブラウザで開いたサイトのすべてのタブで共有され、消すまで残ります。
  • defineMemoryState — JavaScript の実行環境。型付きの共有の箱で、リロードで初期値に戻ります。

Provider はありません。URL・履歴エントリ・localStorage はもともとブラウザに 1 つずつしかなく、ストアはそれをそのまま映すので、区切る範囲がありません。

4 つの置き場所の違いと選び方

インストール

zod と一緒にインストールします。スキーマはブラウザにも届くので、アプリがすでに classic の zod を読み込んでいるのでなければ、軽い zod/mini を使ってください。

npm install @k8ordo/state zod

peer dependencies

パッケージバージョン用途
react≥19.3.0useAppState
zod^4.4.3スキーマ。zodzod/mini のどちらでも動きます
@k8ordo/router^0.1.0任意。href のパスをルート表で型付けするとき。型だけの依存で、実行時には読み込まれません
typescript≥7.0.2任意。同梱の型定義
@types/react≥19.3.0任意。同梱の型定義

ブラウザでは Navigation API を前提にします。Baseline の newly available に達している機能なので、polyfill もフォールバックもありません。

定義を 1 か所に書く

定義は use client を付けないモジュールに置き、Server Component とクライアントコンポーネントの両方から import します。定義はスキーマと純粋な関数だけのデータでストアを持たないので、サーバーで import しても状態は生まれません。

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

export const productListState = definePageState('product-list', {
  url: z.object({
    q: z._default(z.string(), ''),
    page: z._default(z.coerce.number().check(z.int(), z.gte(1)), 1),
    sort: z._default(z.enum(['new', 'price']), 'new'),
  }),
});
  • use client のファイルから export すると、Server Component には値ではなく client reference が届き、parseUrlhref も呼べません。
  • 第 1 引数の 'product-list' は状態の識別子で、ストアの登録先や保存先の名前になります。同じ種類の定義どうしでキーが重なると 1 つのストアを黙って共有するので、アプリ全体のグローバル名として扱ってください。
  • URL のパラメータはいつでも欠けうるので、どのフィールドも欠けたまま読めなければなりません。z._default()(classic の zod では .default())か z.optional() を付けます。付け忘れたフィールドは、モジュールの読み込み時にフィールド名付きのエラーになります。URL は文字列しか運ばないので、数値は z.coerce.number() で受けます。

コンポーネントで使う

useAppState(definition) はどの置き場所でも同じフックで、[state, update] を返します。update() の値は次の描画にすぐ反映され、URL への書き込みは同じハンドラの中の呼び出しをまとめて 1 回の遷移になります。

// src/routes/products/_parts/product-list.tsx
'use client';

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

import type { Product } from '../../../data/products';
import { productListState } from '../../../state/products';

const PAGE_SIZE = 20;

type Props = {
  products: readonly Product[];
};

export function ProductList({ products }: Props) {
  const [{ q, page, sort }, update] = useAppState(productListState);

  const visible = products
    .filter((product) => product.name.includes(q))
    .toSorted((a, b) =>
      sort === 'price' ? a.price - b.price : b.createdAt - a.createdAt,
    )
    .slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);

  return (
    <section>
      <button
        onClick={() => {
          update({ sort: sort === 'new' ? 'price' : 'new', page: 1 });
        }}
        type="button"
      >
        {sort === 'new' ? 'Sort by price' : 'Sort by newest'}
      </button>
      <ul>
        {visible.map((product) => (
          <li key={product.id}>{product.name}</li>
        ))}
      </ul>
      <button
        disabled={page === 1}
        onClick={() => {
          update({ page: page - 1 }, { history: 'push' });
        }}
        type="button"
      >
        Previous
      </button>
      <button
        onClick={() => {
          update({ page: page + 1 }, { history: 'push' });
        }}
        type="button"
      >
        Next
      </button>
    </section>
  );
}

並び替えは既定の replace で現在のエントリを書き換え、ページ送りは戻るボタンで 1 ページずつ戻れるように { history: 'push' } を渡しています。

ページに置く

@k8ordo/static@k8ordo/server のページは Server Component です。リンクは href で組み立てます。指定しなかったフィールドは既定値として扱われ、既定値のフィールドはクエリから省かれるので、同じ状態はいつも同じ最短の URL になります。

// src/routes/products/page.tsx
import { products } from '../../data/products';
import { productListState } from '../../state/products';
import { ProductList } from './_parts/product-list';

export default function ProductsPage() {
  return (
    <>
      <nav>
        <a href={productListState.href('/products')}>All products</a>
        <a href={productListState.href('/products', { sort: 'price' })}>
          Cheapest first
        </a>
      </nav>
      <ProductList products={products} />
    </>
  );
}

このフレームワークのページは search params を受け取りません(受け取るのは paramspathname@k8ordo/server ではさらに request)。サーバーの描画は url スロットの既定値で行われ、ハイドレーションの次の描画から実際の URL の値に切り替わります。上の例で一覧の絞り込みをクライアントコンポーネントの中で行っているのはそのためです。

サーバーで読む

ページに search params を渡すルーター(たとえば Next.js の App Router)の下では、parseUrl で型付きに読めます。欠けたパラメータには既定値が入り、スキーマが受け付けない値はそのフィールドだけが既定値に戻ります。読み取りが throw することはありません。

// src/app/products/page.tsx
import { products } from '../../data/products';
import { productListState } from '../../state/products';
import { ProductList } from './product-list';

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

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

  return <ProductList initialUrl={url} products={products} />;
}
  • たとえば ?q=shoes&page=0{ q: 'shoes', page: 1, sort: 'new' } として読まれます。
  • 読んだ値を initialUrl として渡し、下のように ProductList の中で useAppState(productListState, { initialUrl }) に渡すと、サーバーの描画とハイドレーションの描画も実際の値で行われ、既定値からのちらつきが出ません。
// src/app/products/product-list.tsx
'use client';

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

import type { Product } from '../../data/products';
import { productListState } from '../../state/products';

const PAGE_SIZE = 20;

type Props = {
  products: readonly Product[];
  initialUrl: OutputOf<typeof productListState.url>;
};

export function ProductList({ products, initialUrl }: Props) {
  const [{ q, page, sort }, update] = useAppState(productListState, {
    initialUrl,
  });

  const visible = products
    .filter((product) => product.name.includes(q))
    .toSorted((a, b) =>
      sort === 'price' ? a.price - b.price : b.createdAt - a.createdAt,
    )
    .slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);

  return (
    <section>
      <button
        onClick={() => {
          update({ sort: sort === 'new' ? 'price' : 'new', page: 1 });
        }}
        type="button"
      >
        {sort === 'new' ? 'Sort by price' : 'Sort by newest'}
      </button>
      <ul>
        {visible.map((product) => (
          <li key={product.id}>{product.name}</li>
        ))}
      </ul>
      <button
        disabled={page === 1}
        onClick={() => {
          update({ page: page - 1 }, { history: 'push' });
        }}
        type="button"
      >
        Previous
      </button>
      <button
        onClick={() => {
          update({ page: page + 1 }, { history: 'push' });
        }}
        type="button"
      >
        Next
      </button>
    </section>
  );
}

サルベージの規則、initialUrl、型付きのリンク

ルーターに求めること

URL を書き換える update()navigation.navigate() を呼びます。これをページの読み込みではなく状態の変更として扱うには、Navigation API の navigate イベントを intercept するルーターが要ります。@k8ordo/router はそういうルーターで、@k8ordo/static@k8ordo/server のページもその上で動きます。pathname が変わらない遷移はページの切り替えではなく状態の変更として処理され、何も再マウントされず、スクロールもフォーカスも動きません。

クライアントでのそれ以外の操作はルーターを問いません。href のリンク、GET フォーム、entry のフィールドだけを変える更新、localStorage とメモリの更新は、どのルーターの下でも動きます。

次に読む