@k8ordo/state

更新

useAppState はどの置き場所でも同じフックです。返ってくる update() は、書いた値をその場で検証して描画に反映し、書き込みをまとめ、フィールドごとに置き場所へ振り分けます。

useAppState

クライアントコンポーネントで呼び、[state, update] を返します。Provider は要りません。ブラウザのストアは最初に呼ばれたときに作られ、定義の種類とキーで登録されます。

呼び出し返る状態再描画されるとき
useAppState(definition)すべてのフィールド宣言したどのフィールドが変わっても
useAppState(definition, ['q', 'page'])列挙したフィールドだけ列挙したフィールドが変わったとき
useAppState(definition, []){}再描画されない(書き込み専用)
  • 状態の形は定義の種類で決まります。definePageStateurlentry を平らにマージしたもの、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)pageupdate({ page: 0 }) を渡すと、?page=0 と同じく既定値の 1 になります。スキーマが拒む値が描画に出ることはありません。 サルベージの規則
  • URL に書けない値(Date など)は、何かを書き込む前に update() 自体が throw します。ハンドルを reject するだけでは、ハンドルを待たない普通の呼び方では誰も気づけないからです。
  • 定義に無いフィールドは型エラーです。型を迂回して渡されたときは TypeError を throw します。
  • defineMemoryState はスキーマを持たないので、渡した値がそのまま入ります。

書き込みのまとめ方

definePageStatedefineLocalState では、同じハンドラの中で呼んだ update() が定義ごとに 1 回の書き込みにまとまり、すべて同じハンドルを返します。上の例の Clear にある 2 回の update() も、1 回の遷移になります。defineMemoryState にはまとめる書き込みが無く、呼び出しごとにその場で反映され、それぞれ解決済みのハンドルを返します。書き込み先は、バッチが実際に値を変えたフィールドで決まります。

バッチが変えたもの書き込み必要なルーター
url のフィールド(entry を含んでもよい)navigation.navigate(url, { history, state })Navigation API を intercept するもの
entry のフィールドだけnavigation.updateCurrentEntry({ state })不要
defineLocalStatelocalStorage.setItem を 1 回不要
defineMemoryStateその場で置き換え(まとめない)不要
  • まとまるのは、同期的に続けて呼んだ update() です。間に await を挟むと、別々のバッチ(別々の書き込みとハンドル)になります。
  • definePageState のバッチが今の値と同じところで終われば、遷移もエントリの書き換えも起きません。defineLocalState のバッチは、結果が同じでも行を書き込みます(まだ行が無ければ作ります)。
  • URL とエントリ状態は共有の場所です。書き換えるのは自分のパラメータと自分の名前空間だけで、ほかの定義のパラメータや、誰のものでもない utm_source のようなパラメータ、エントリ状態にあるほかの値は、どの書き込みでも残ります。
  • 書き込む値は、描画に出した値ではなく、その時点のブラウザの値(URL・エントリ状態・localStorage)にバッチの変更を重ねて作ります。ほかのタブやほかの定義がその間に書いた値を巻き戻すことはありません。

ハンドル: committedfinished

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/uiButtononAction)の中でも同じように待てます。@k8ordo/router の下では、別のページの読み込み中に url の値を変える更新はページの切り替えになりますが、ページの切り替えはアクションに加わらないので、finished はそのページが画面に出た時点で解決します。

history: replacepush

既定は replace です。更新は今のエントリに手を入れるもので、戻るボタンで 1 つずつ取り消すものではないからです。戻るボタンで取り消せるべき更新(ページ送りや、手順として扱いたいタブの切り替え)にだけ { history: 'push' } を渡します。

  • この引数(型は UpdateOptions)は definePageState にだけあります。遷移を伴うのはこの種類だけで、defineLocalStatedefineMemoryStateupdate() に渡すと型エラーです。
  • 同じバッチの中で 1 回でも push が指定されれば、そのバッチの遷移は push になります。
  • entry の値だけが変わるバッチは updateCurrentEntry() で書かれ、何も変わらないバッチは何も書き込まないので、どちらも push を指定しても新しいエントリは作られません。
  • ページの移動は @k8ordo/routernavigateTo(既定は push)、状態の変更は update(既定は replace)で行います。

購読の粒度

定義がキーの集合(スキーマのキー、メモリなら初期値のキー)を固定しているので、変更の検出はキー単位で正確です。['q'] だけを購読するコンポーネントは、page が変わっても再描画されません。

  • キーの配列はインラインで書いてかまいません。内部で正規化されるので、useMemo は要りません。
  • 「変わったか」は構造で比べます。配列とプレーンなオブジェクトは中身で、DateMap・クラスのインスタンスは参照で比べます。変わらなかったフィールドは前と同じ参照を保つので、memo や依存配列にそのまま渡せます。
  • 更新の頻度が大きく違う状態は、別々の定義に分けてください。定義が購読の境界です。

動きを確かめる

下のデモは本物の definePageState で、ab を 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 で作り直すためのものです。