URLに状態を置く
URLに置いた状態は、リンクを渡した相手にも同じ画面を見せます。その代わり、URLは文字列しか運ばず、誰でも書き換えられます。このページでは、urlのスキーマの書き方と、書き換えられた値がどう読まれるかを説明します。
このページの内容
どのフィールドも欠けてよいようにする
URLのパラメータは、いつでも欠けえます。リンクを手で短くされることもあれば、フィールドを足す前に作られたリンクが開かれることもあります。
そのため、どのフィールドにも.default()か.optional()を付けます。付け忘れたフィールドがあると、定義はモジュールを読み込んだ時点で、そのフィールドの名前を挙げて投げます。最初のクリックを待たずに、読み込んだ時点で分かります。
既定値を決められないフィールドは.optional()にします。パラメータが無ければ、値はundefinedです。
文字列から型を読む
URLが運ぶのは文字列だけです。そのため、スキーマは文字列から自分の型を読み出せる書き方にします。
catalog-state.tsimport { 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と同じ道を通すためです。そのため、自分が書いたクエリを読み返せないフィールドは、書くたびに既定値へ戻ってしまいます。そうなると決まっている書き方は、モジュールの読み込みで拒まれます。
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()で始まります。
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は誰でも書き換えられるので、スキーマに合わない値が届くことがあります。そうした値はそのフィールドだけが既定値に戻り、ほかのフィールドは読めた値を保ちます。読むときに投げることはありません。
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.tsexport 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ができるので、リンクやブックマーク、キャッシュのキーがそろいます。
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()がクエリを書き直すときに省かれます。
URLの読み方を試す
入力したクエリを、上のcatalogStateと同じ定義のparseUrlで読みます。その下には、読んだ値からsearchで作り直したクエリを表示します。このページのURLは書き換えません。
q"lamp"page2inStockfalsetags[]
- 作り直したクエリ
q=lamp&page=2
試してみる
page=2をpage=0に書き換えると、pageだけが既定値の1に戻り、qは"lamp"のまま残ります。- 末尾に
&inStock=yesを足すと、inStockがtrueになります。作り直したクエリではinStock=trueと書かれます。 - さらに
&tags=sale&tags=newを足すと、tagsが配列として読まれます。そのうちnewをoldに変えると、選択肢に無い値が混ざるので、tags全体が[]に戻ります。