@k8ordo/state

URLに状態を置く

URLに置いた状態は、リンクを渡した相手にも同じ画面を見せます。その代わり、URLは文字列しか運ばず、誰でも書き換えられます。このページでは、urlのスキーマの書き方と、書き換えられた値がどう読まれるかを説明します。

このページの内容

どのフィールドも欠けてよいようにする

URLのパラメータは、いつでも欠けえます。リンクを手で短くされることもあれば、フィールドを足す前に作られたリンクが開かれることもあります。

そのため、どのフィールドにも.default()か.optional()を付けます。付け忘れたフィールドがあると、定義はモジュールを読み込んだ時点で、そのフィールドの名前を挙げて投げます。最初のクリックを待たずに、読み込んだ時点で分かります。

既定値を決められないフィールドは.optional()にします。パラメータが無ければ、値はundefinedです。

文字列から型を読む

URLが運ぶのは文字列だけです。そのため、スキーマは文字列から自分の型を読み出せる書き方にします。

catalog-state.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),
    inStock: z.stringbool().default(false),
    tags: z.array(z.enum(['sale', 'new'])).default([]),
  }),
});
  • 文字列:z.string()のまま受けます。選択肢の決まった値はz.enum()です。
  • 数:z.coerce.number()で受けます。?page=2の"2"が2になります。
  • 真偽値:z.stringbool()で受けます。"true"をtrueとして読み、trueを書くときは"true"と書きます。
  • 配列:z.array()で受けます。同じ名前のパラメータを繰り返して書き(?tags=sale&tags=new)、既定値は[]にします。

配列ではないフィールドのパラメータが繰り返されたときは、最初の値を読みます。スキーマに無いパラメータは読まずに残すので、utm_sourceのようなほかの用途のパラメータとも並べられます。

読み込みの時点で拒まれる書き方

update()は、書いた値をいったんクエリ文字列にしてから読み直し、その結果を描画に出します。訪問者が開いたURLと同じ道を通すためです。そのため、自分が書いたクエリを読み返せないフィールドは、書くたびに既定値へ戻ってしまいます。そうなると決まっている書き方は、モジュールの読み込みで拒まれます。

ts
inStock: z.boolean().default(false),
inStock: z.stringbool().default(false),

1つ目は、z.boolean()とz.coerce.boolean()です。URLの"false"は、z.boolean()にとっては真偽値ではなく、z.coerce.boolean()にとってはtrueです。配列の要素に書いても拒まれ、エラーはurl boolean fields must use z.stringbool()で始まります。

ts
tags: z.array(z.string()).optional(),
tags: z.array(z.string()).default([]),

2つ目は、.optional()の配列と、[]以外を既定値にした配列です。パラメータが無いことと空の配列は同じURLなので、既定値が[]でないと、空の配列を書けなくなるからです。エラーはurl array fields must default to []で始まります。

URLで表せない値は、書こうとした時点で拒まれます。たとえばz.date()のフィールドにDateを渡すと、update()やhrefがフィールドの名前を挙げて投げます。

書き換えられた値を読む

URLは誰でも書き換えられるので、スキーマに合わない値が届くことがあります。そうした値はそのフィールドだけが既定値に戻り、ほかのフィールドは読めた値を保ちます。読むときに投げることはありません。

ts
const read = (query: string) =>
  catalogState.parseUrl(new URLSearchParams(query));

read('q=lamp&page=0');
// { q: 'lamp', page: 1, inStock: false, tags: [] }

read('page=abc&inStock=true');
// { q: '', page: 1, inStock: true, tags: [] }

read('tags=sale&tags=new');
// { q: '', page: 1, inStock: false, tags: ['sale', 'new'] }

read('tags=sale&tags=old');
// { q: '', page: 1, inStock: false, tags: [] }

read('q=red&q=blue');
// { q: 'red', page: 1, inStock: false, tags: [] }

配列は1つのフィールドとして扱います。要素が1つでも拒まれれば、配列全体が[]に戻ります。

スキーマ全体に付けた.refine()は、フィールドごとに拾ったあとの組み合わせに対して、もう一度走ります。組み合わせが拒まれたときは、すべてのフィールドが既定値に戻ります。そのため.refine()は、すべてが既定値の状態を受け付けなければなりません。受け付けないと、定義が読み込みの時点で投げます。

price-state.ts
export const priceState = definePageState('price', {
  url: z
    .object({
      min: z.coerce.number().min(0).default(0),
      max: z.coerce.number().min(0).default(1000),
    })
    .refine((range) => range.min <= range.max),
});

priceState.parseUrl(new URLSearchParams('min=500&max=100'));
// { min: 0, max: 1000 }

既定値を省いた、いつも同じURL

クエリを書くときは、既定値と同じフィールドを省きます。同じ状態からはいつも同じ、いちばん短いURLができるので、リンクやブックマーク、キャッシュのキーがそろいます。

ts
catalogState.search({ page: 1, tags: ['sale'] });
// 'tags=sale'

catalogState.search({ tags: ['sale'], q: 'desk lamp' });
// 'q=desk+lamp&tags=sale'

パラメータは、スキーマに書いた順に並びます。手で?page=1と書かれたURLも同じように読めます。このpage=1は、urlの値を変える次のupdate()がクエリを書き直すときに省かれます。

Playground

URLの読み方を試す

入力したクエリを、上のcatalogStateと同じ定義のparseUrlで読みます。その下には、読んだ値からsearchで作り直したクエリを表示します。このページのURLは書き換えません。

q
"lamp"
page
2
inStock
false
tags
[]
作り直したクエリ
q=lamp&page=2

試してみる

  1. page=2をpage=0に書き換えると、pageだけが既定値の1に戻り、qは"lamp"のまま残ります。
  2. 末尾に&inStock=yesを足すと、inStockがtrueになります。作り直したクエリではinStock=trueと書かれます。
  3. さらに&tags=sale&tags=newを足すと、tagsが配列として読まれます。そのうちnewをoldに変えると、選択肢に無い値が混ざるので、tags全体が[]に戻ります。
k8ordo

Baselineに入った機能を、制限なく使うReactのライブラリ群。

© 2026 k8o — MIT License

組版:Noto Sans JP / M PLUS 2