Get Started
@k8ordo/state は、状態を「どこに住むか」で宣言します。このページでは URL に置く状態を 1 つ定義し、インストールから、コンポーネントでの利用、サーバーでの読み取りまでを通して書きます。
考え方
状態を持つとき、ふつうは先にストアを選び、永続化や URL との同期はあとから足します。ここでは順番が逆で、最初に置き場所を決めます。置き場所が決まれば、寿命(いつ消えるか)と共有範囲(誰に見えるか)が決まります。境界を越えて値が戻ってくる置き場所(URL・履歴エントリ・localStorage)はスキーマを 1 つずつ持ち、そこからサーバーでの読み取り・正規化されたリンク・古いデータのサルベージ・キー単位の購読が導かれます。メモリは境界を越えないので、スキーマを持たない型付きの箱です。
definePageStateのurl— URL の search params。リンクで共有でき、search を渡すルーターならサーバーで読めます。definePageStateのentry— 履歴エントリの隠れた状態。戻る・進むで復元されますが、URL には出ません。defineLocalState— localStorage。同じブラウザで開いたサイトのすべてのタブで共有され、消すまで残ります。defineMemoryState— JavaScript の実行環境。型付きの共有の箱で、リロードで初期値に戻ります。
Provider はありません。URL・履歴エントリ・localStorage はもともとブラウザに 1 つずつしかなく、ストアはそれをそのまま映すので、区切る範囲がありません。
インストール
zod と一緒にインストールします。スキーマはブラウザにも届くので、アプリがすでに classic の zod を読み込んでいるのでなければ、軽い zod/mini を使ってください。
npm install @k8ordo/state zodpeer dependencies
| パッケージ | バージョン | 用途 |
|---|---|---|
| react | ≥19.3.0 | useAppState |
| zod | ^4.4.3 | スキーマ。zod と zod/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 が届き、parseUrlもhrefも呼べません。- 第 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 を受け取りません(受け取るのは params と pathname、@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>
);
}ルーターに求めること
URL を書き換える update() は navigation.navigate() を呼びます。これをページの読み込みではなく状態の変更として扱うには、Navigation API の navigate イベントを intercept するルーターが要ります。@k8ordo/router はそういうルーターで、@k8ordo/static と @k8ordo/server のページもその上で動きます。pathname が変わらない遷移はページの切り替えではなく状態の変更として処理され、何も再マウントされず、スクロールもフォーカスも動きません。
クライアントでのそれ以外の操作はルーターを問いません。href のリンク、GET フォーム、entry のフィールドだけを変える更新、localStorage とメモリの更新は、どのルーターの下でも動きます。