更新
useAppState はどの置き場所でも同じフックです。返ってくる update() は、書いた値をその場で検証して描画に反映し、書き込みをまとめ、フィールドごとに置き場所へ振り分けます。
useAppState
クライアントコンポーネントで呼び、[state, update] を返します。Provider は要りません。ブラウザのストアは最初に呼ばれたときに作られ、定義の種類とキーで登録されます。
| 呼び出し | 返る状態 | 再描画されるとき |
|---|---|---|
useAppState(definition) | すべてのフィールド | 宣言したどのフィールドが変わっても |
useAppState(definition, ['q', 'page']) | 列挙したフィールドだけ | 列挙したフィールドが変わったとき |
useAppState(definition, []) | {} | 再描画されない(書き込み専用) |
- 状態の形は定義の種類で決まります。
definePageStateはurlとentryを平らにマージしたもの、defineLocalStateはスキーマの出力、defineMemoryStateは初期値の型です。 - サーバーの描画とハイドレーションの描画は、ブラウザの値ではなく既定値で行われ(
defineMemoryStateは初期値、initialUrlを渡したときはそのurlの値)、その次の描画から実際の値になります。 - 3 つ目の引数(キーを省くときは 2 つ目)の
{ initialUrl }は、urlスロットを持つdefinePageStateにだけ渡せます。ほかの種類に渡すと型エラーです。 読んだ値を最初の描画に渡す - 定義をモジュールのトップレベルに置いている限り、
updateは描画をまたいで同じ関数なので、エフェクトの依存配列に入れても再実行を招きません。 AnyStateは 3 種類の定義の union 型で、どの定義でも受け取るヘルパーの型付けに使えます。
update(patch)
変えたいフィールドだけのオブジェクトを渡します。今の状態から次の値を作るときは関数を渡します。その引数には、同じバッチでまだ書き込まれていない更新も反映した状態が入ります。
// src/catalog/tag-filter.tsx
'use client';
import { useAppState } from '@k8ordo/state';
import { catalogState } from '../state/catalog';
type Props = {
tags: readonly string[];
};
export function TagFilter({ tags }: Props) {
const [{ tags: selected }, update] = useAppState(catalogState, ['tags']);
return (
<div>
{tags.map((tag) => (
<button
aria-pressed={selected.includes(tag)}
key={tag}
onClick={() => {
update((current) => ({
tags: current.tags.includes(tag)
? current.tags.filter((other) => other !== tag)
: [...current.tags, tag],
page: 1,
}));
}}
type="button"
>
{tag}
</button>
))}
<button
onClick={() => {
update({ tags: [] });
update({ page: 1 });
}}
type="button"
>
Clear
</button>
</div>
);
}update()は同期的に適用され、次の描画は新しい値を見ます。書き込み自体はその後にまとめて行われます。- パッチはその場でスキーマを通ります。
urlのフィールドは URL から届くときと同じ道(クエリに書いて読み直す)を通るので、z.gte(1)のpageにupdate({ page: 0 })を渡すと、?page=0と同じく既定値の1になります。スキーマが拒む値が描画に出ることはありません。 サルベージの規則 - URL に書けない値(
Dateなど)は、何かを書き込む前にupdate()自体が throw します。ハンドルを reject するだけでは、ハンドルを待たない普通の呼び方では誰も気づけないからです。 - 定義に無いフィールドは型エラーです。型を迂回して渡されたときは
TypeErrorを throw します。 defineMemoryStateはスキーマを持たないので、渡した値がそのまま入ります。
書き込みのまとめ方
definePageState と defineLocalState では、同じハンドラの中で呼んだ update() が定義ごとに 1 回の書き込みにまとまり、すべて同じハンドルを返します。上の例の Clear にある 2 回の update() も、1 回の遷移になります。defineMemoryState にはまとめる書き込みが無く、呼び出しごとにその場で反映され、それぞれ解決済みのハンドルを返します。書き込み先は、バッチが実際に値を変えたフィールドで決まります。
| バッチが変えたもの | 書き込み | 必要なルーター |
|---|---|---|
url のフィールド(entry を含んでもよい) | navigation.navigate(url, { history, state }) | Navigation API を intercept するもの |
entry のフィールドだけ | navigation.updateCurrentEntry({ state }) | 不要 |
defineLocalState | localStorage.setItem を 1 回 | 不要 |
defineMemoryState | その場で置き換え(まとめない) | 不要 |
- まとまるのは、同期的に続けて呼んだ
update()です。間にawaitを挟むと、別々のバッチ(別々の書き込みとハンドル)になります。 definePageStateのバッチが今の値と同じところで終われば、遷移もエントリの書き換えも起きません。defineLocalStateのバッチは、結果が同じでも行を書き込みます(まだ行が無ければ作ります)。- URL とエントリ状態は共有の場所です。書き換えるのは自分のパラメータと自分の名前空間だけで、ほかの定義のパラメータや、誰のものでもない
utm_sourceのようなパラメータ、エントリ状態にあるほかの値は、どの書き込みでも残ります。 - 書き込む値は、描画に出した値ではなく、その時点のブラウザの値(URL・エントリ状態・localStorage)にバッチの変更を重ねて作ります。ほかのタブやほかの定義がその間に書いた値を巻き戻すことはありません。
ハンドル: committed と finished
update() は navigation.navigate() と同じ形の、2 つの Promise を持つオブジェクト(UpdateHandle)を返します。Promise そのものではないので、無視しても floating promise の lint に掛かりません。ハンドルを無視するのが普通の使い方です。
| Promise | 解決するとき |
|---|---|
committed | 書き込みが置き場所(履歴エントリ・localStorage・メモリ)に入ったとき |
finished | 書き込みの後にルーターが行う処理まで終わったとき |
@k8ordo/routerの下では、pathname が変わらない遷移に読み込みも描画も伴わないので、finishedはその遷移が落ち着いた時点で解決します。新しい値はupdate()がすでに描画しています。- 遷移を伴わない書き込み(
entryだけ・local・何も変わらない page のバッチ)のハンドルは、ハンドラの直後のマイクロタスクでバッチを書き込んだ時点で解決します。defineMemoryStateのハンドルは、返った時点で解決済みです。 - あとから来た遷移に追い越された遷移のハンドルは、
AbortErrorで reject します。localStorage への保存に失敗した(容量の超過など)ときも reject しますが、描画された値はそのまま残ります。どちらも、ハンドルを待っていなければ unhandled rejection にはなりません。
書き込みを待つ必要があるときは、finished を待ちます。以下の例は、次のページに移ったあとで見出しにフォーカスを移します。
// src/catalog/results.tsx
'use client';
import { useAppState } from '@k8ordo/state';
import { useRef } from 'react';
import { catalogState } from '../state/catalog';
const isAbort = (error: unknown) =>
error instanceof DOMException && error.name === 'AbortError';
export function Results() {
const [{ page }, update] = useAppState(catalogState, ['page']);
const heading = useRef<HTMLHeadingElement>(null);
return (
<section>
<h2 ref={heading} tabIndex={-1}>
Page {page}
</h2>
<button
onClick={async () => {
try {
await update({ page: page + 1 }, { history: 'push' }).finished;
} catch (error) {
if (!isAbort(error)) throw error;
return;
}
heading.current?.focus();
}}
type="button"
>
Next page
</button>
</section>
);
}追い越されうる更新を待つときは、例のように AbortError だけを無視し、それ以外のエラーは投げ直します。
非同期アクション(startTransition(async …)・useTransition・@k8ordo/ui の Button の onAction)の中でも同じように待てます。@k8ordo/router の下では、別のページの読み込み中に url の値を変える更新はページの切り替えになりますが、ページの切り替えはアクションに加わらないので、finished はそのページが画面に出た時点で解決します。
history: replace と push
既定は replace です。更新は今のエントリに手を入れるもので、戻るボタンで 1 つずつ取り消すものではないからです。戻るボタンで取り消せるべき更新(ページ送りや、手順として扱いたいタブの切り替え)にだけ { history: 'push' } を渡します。
- この引数(型は
UpdateOptions)はdefinePageStateにだけあります。遷移を伴うのはこの種類だけで、defineLocalState・defineMemoryStateのupdate()に渡すと型エラーです。 - 同じバッチの中で 1 回でも
pushが指定されれば、そのバッチの遷移はpushになります。 entryの値だけが変わるバッチはupdateCurrentEntry()で書かれ、何も変わらないバッチは何も書き込まないので、どちらもpushを指定しても新しいエントリは作られません。- ページの移動は
@k8ordo/routerのnavigateTo(既定はpush)、状態の変更はupdate(既定はreplace)で行います。
購読の粒度
定義がキーの集合(スキーマのキー、メモリなら初期値のキー)を固定しているので、変更の検出はキー単位で正確です。['q'] だけを購読するコンポーネントは、page が変わっても再描画されません。
- キーの配列はインラインで書いてかまいません。内部で正規化されるので、
useMemoは要りません。 - 「変わったか」は構造で比べます。配列とプレーンなオブジェクトは中身で、
Date・Map・クラスのインスタンスは参照で比べます。変わらなかったフィールドは前と同じ参照を保つので、memoや依存配列にそのまま渡せます。 - 更新の頻度が大きく違う状態は、別々の定義に分けてください。定義が購読の境界です。
動きを確かめる
下のデモは本物の definePageState で、a と b を URL に、c を履歴エントリに置いています。ボタンは useAppState(def, []) で何も購読しない書き込み専用のコンポーネントにあり、その下に 3 つの購読それぞれの描画回数と、ブラウザが実際に受け取った書き込みを表示します。
| 購読 | state | 描画回数 |
|---|---|---|
useAppState(def) | {"a":0,"b":0,"c":0} | |
useAppState(def, ['a']) | {"a":0} | |
useAppState(def, ['b']) | {"b":0} |
a + 1 を押しても、['b'] の購読の描画回数は増えません。a + 1 (×3) は 3 回の update() ですが、ログに出る遷移は 1 回です。c + 1 は遷移ではなく updateCurrentEntry になります。a = -1 はスキーマ(z.gte(0))に拒まれて既定値の 0 になり、a がすでに 0 なら何も書き込みません。
入力のたびに URL を書き換えない
入力途中の値は DOM か React のローカルな状態に持たせ、送信・フォーカスが外れたとき・ページ送りのような区切りで update() を呼びます。@k8ordo/form と同じ線の引き方です。定義の値を React の状態に写して同期させることはしないでください。
// src/catalog/search-box.tsx
'use client';
import { useAppState } from '@k8ordo/state';
import { catalogState } from '../state/catalog';
export function SearchBox() {
const [{ q }, update] = useAppState(catalogState, ['q']);
return (
<form
onSubmit={(event) => {
event.preventDefault();
const value = new FormData(event.currentTarget).get('q');
update({ q: typeof value === 'string' ? value : '', page: 1 });
}}
>
<input
aria-label="Search"
defaultValue={q}
key={q}
name="q"
type="search"
/>
<button type="submit">Search</button>
</form>
);
}key={q} は、戻るボタンなど外から q が変わったときに、非制御の入力を新しい defaultValue で作り直すためのものです。