読み取りとリンク
ページに search を渡すルーターの下では、サーバーが url スロットを parseUrl で読みます。リンクは定義から組み立てます。localStorage の値も、ハイドレーションより前に読めます。
parseUrl
parseUrl(input) は url スロットを読み、スキーマの出力型の値を返します。input は URLSearchParams か、フレームワークがページに渡すオブジェクトの形(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 の行・definePageState と defineLocalState の update() に渡した値にも適用されます。
@k8ordo/static・@k8ordo/server のページ
このフレームワークのページは search params を受け取りません。受け取るのは params と pathname(@k8ordo/server ではさらに、ヘッダーと Cookie を持つ request)です。pathname はルーターのもので、search は useAppState がブラウザで読みます。
サーバーの描画は url スロットの既定値で行われ、ハイドレーションの次の描画から実際の URL が使われます。これは回避すべき欠落ではありません。ルーターは pathname が変わらない遷移を何も読み込まずに intercept するので、search に依存したサーバーの描画は、最初の読み込みでは正しくても、最初の update() の後には古くなります。
href と search は純粋な関数なので、この制約を受けず、Server Component でもそのまま使えます。
読んだ値を最初の描画に渡す
ページに search を渡すルーター(Next.js の App Router など)では、parseUrl の結果をクライアントコンポーネントに渡し、useAppState の initialUrl にします。サーバーの描画とハイドレーションの描画が実際の 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 フォームで行うのが向いています。 ルーターとの組み合わせ
href と search
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など)を渡すと、hrefとsearchは throw します。 - 戻り値の型にはパスのリテラルが残るので、型付きルートの検査がクエリを取り除いてパスを確かめられます。
entryだけの定義では、hrefはbaseをそのまま返し、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/routerのRouteOfから型だけで導かれるので、ルーターは任意の peer のままで、実行時には読み込まれません。
@k8ordo/static と @k8ordo/server では、アプリ自身の package.json の dependencies か devDependencies に @k8ordo/state があれば、この拡張が routes/ から .k8ordo/register.gen.ts に生成されます(推移的な依存は数えません)。生成された宣言と重なるので、そこでは手書きしないでください。
表を持たないルーターでは、そのルーターのパスの union を path に登録します。Next.js なら next の Route です。
// 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 の仕組み