定義
状態設計のはしごは、「この状態を union でモデリングすべきか?」を判断するための 3 段の順序です。上から順に確認して、手前の段で片がついたら、それより下には降りません。
- 導出できるなら保存しません。 元の値から計算します。
- ライブラリがすでに union を所有しているなら、そのまま消費します。 コピーしません。
- それでも残る本当の client 状態だけを union + 意図を表す関数にします。
大事なのは「良い union をどう書くか」ではなく、まず 「union を作る必要があるのか」 を問うことです。
なぜ必要か
状態モデリングを学ぶと、たいていは 3 段目から始めてしまいます。type State = { status: 'idle' } | { status: 'loading' } | ... を書いて、なんだか良い仕事をした気になるんですよね。でも実際にバグが出る場所は、たいてい 1 段目と 2 段目です。
1 段目を飛ばすと、こんなコードになります。
const [items, setItems] = useState<Item[]>([]);
const [itemCount, setItemCount] = useState(0); // items.length と同じ事実
itemCount は items から計算できる値です。同じ事実を 2 か所が所有しているので、いつか片方だけが更新されます。型をどれだけ丁寧に書いても、このズレは防げません。
2 段目を飛ばすと、こんなコードになります。
const { data, isLoading, isError } = useQuery(...);
const [state, setState] = useState<'idle' | 'loading' | 'done' | 'failed'>('idle');
useEffect(() => {
if (isLoading) setState('loading');
else if (isError) setState('failed');
else if (data) setState('done');
}, [isLoading, isError, data]);
クエリライブラリはすでに status の union を持っています。しかも 最新の呼び出しを基準にした時間軸の処理まで含めてです。それを useState で作り直すと、レスポンスの順序が入れ替わったときにライブラリは知っていても自分のコピーは知りません。1 レンダー遅れてついてくる 2 つ目の真実ができてしまいます。
動作原理
1 段目 — 導出できるなら保存しません
判定の質問は 1 つだけです。「この値を他の値から計算できますか?」
// ❌ 同じ事実を 2 か所が所有しています
const [items, setItems] = useState<Item[]>([]);
const [hasItems, setHasItems] = useState(false);
// ✅ 所有者は 1 つだけ
const [items, setItems] = useState<Item[]>([]);
const hasItems = items.length > 0;
計算コストが気になったら、そのときに useMemo を使えば十分です。保存はパフォーマンス最適化の手段ではなく、最後の手段です。
2 段目 — ライブラリが所有する union はそのまま使います
サーバーから来るデータの lifecycle は、たいていすでにモデリングされています。TanStack Query の status・fetchStatus、mutation の isPending・isSuccess・isError がまさにそれです。
// ❌ 同じ状態を新しい union で再パッケージ
type NextPageState = 'can-load' | 'loading' | 'exhausted';
// ✅ ライブラリの union を直接消費する
const { status, data, fetchNextPage, hasNextPage } = useInfiniteQuery(...);
ここに付いてくるルールが 2 つあります。
ローディングと失敗の基本は、コンポーネント内の分岐ではなく境界です。 無条件に実行される最初の取得なら、useSuspenseQuery + 局所的な <Suspense> + Error Boundary に持ち上げて、コンポーネント本体から分岐をまるごとなくします。条件付き query やキャンセル制約のように境界へ持ち上げられない残りにだけ、分岐を残します。
共通 UI が lifecycle 全体を知る必要はありません。 「次のページをもっと読み込めるか」だけが必要なら、状態の union を渡すのではなく、callback が存在すること自体を「できる」という能力として扱います。
// ❌ 共通コンポーネントが他人の lifecycle を知る必要が出てきます
type Props = { pageState: NextPageState };
// ✅ 関数があればできる、なければできない
type Props = { onLoadMore?: () => void };
3 段目 — 残った client 状態だけを union に
ここまで来たら、いよいよ本物の union を作ります。ただし 2 つだけ守ります。
raw な setter を外に出しません。 hook が返すのは setState ではなく、ドメインの意図を表す関数です。
// ❌ 誰でも好きな状態に変えられます
function useCheckout() {
const [state, setState] = useState<CheckoutState>(...);
return { state, setState };
}
// ✅ 許可された遷移だけを公開します
function useCheckout() {
const [state, setState] = useState<CheckoutState>(...);
return { state, submit, reset, goBack };
}
タグ付きオブジェクトの union は、2 つ以上のメンバーが自分だけのフィールドを持つときだけです。
// ✅ タグ付きオブジェクト — shipping だけに fieldErrors、review だけに quote があります
type CheckoutState =
| { status: "cart"; items: CartItem[] }
| { status: "shipping"; address: Address; fieldErrors: FieldErrors }
| { status: "review"; quote: Quote; agreed: boolean };
// ✅ リテラル union — 付随データがありません。包んでも新しく防げるものはありません
type PaymentBadge = "unpaid" | "paid" | "refunded";
ラベルマップが必要だからというのは、タグ付きオブジェクトを作る根拠になりません。 satisfies Record<PaymentBadge, string> はリテラル union にもそのまま効きます。
実務での適用
状態はデータ、action は兄弟
これが 3 段目で一番よく間違えるポイントです。state の union の中に関数を入れません。
// ❌ 禁止 — stale closure と偽の retry が同時に生まれます
type DetailState =
| { status: "loading" }
| { status: "failure"; retry: () => void };
// ✅ 状態はデータ、action は兄弟として
type DetailState =
| { status: "loading" }
| { status: "failure"; reason: LoadFailure };
function useDetail(id: DetailId): { state: DetailState; retry: () => void };
理由は 2 つあります。
1 つ目は、保存した関数は、自分を作ったレンダーのクロージャに固定されるからです。あとで props やパラメータが変わっても、古い値をキャプチャし続けます。状態は更新されているのに、その中の関数は過去を見ているわけです。
2 つ目は、使えない状態に no-op の action を埋めることになるからです。型を合わせるために retry: () => undefined を入れると、UI は「再試行できる」という嘘の情報を受け取ります。ボタンは見えているのに、押しても何も起きません。
ステートマシンはデフォルトではありません
reducer、遷移表、XState は、順序の違反そのものがドメインエラーになる流れにだけ使います。決済、多段階の送信、楽観的ロールバックのようなものです。
単純な取得のローディング・成功・失敗は 2 段目で終わります。3 段目まで降りたとしても、必要なのは union 1 つと意図を表す関数がいくつかだけです。「状態モデルのドキュメントがある」という事実だけで Event union と遷移関数を作ると、何も防げないマシンが増えるだけです。
不正な状態をいつ分けるか
パースに失敗した ID、存在しないルートパラメータのようなものです。判定基準は 「画面と復旧経路が実際に違うか」 です。
同じなら既存の失敗状態にまとめます。違うなら分けて、その状態だけのフィールドと action をそれぞれ埋めます。要件に区別がないなら、発明せずに聞いてください。
トレードオフ
手前の段で止まるほどコードは減りますが、表現力は落ちます。 導出計算は毎レンダー走り直しますし、ライブラリの union は自分の望む名前ではないかもしれません。だから「自分の union を作った方が楽なのに」という誘惑がずっとあります。
その誘惑に勝つための基準が、所有者が 1 つかどうかです。楽をするために作ったコピーは必ず元とズレる瞬間が来て、そのときのデバッグコストは、最初に節約した時間よりずっと大きくなります。
逆に、はしごを教条的に守りすぎると、ライブラリの API に無理やり合わせるはめになることもあります。条件付き query、placeholder、キャンセル制約のように本当の失格理由があるなら、下の段に降りるのが正解です。ただし、その理由は書き残してください。 書けないなら、たいていそれは失格理由ではなく「慣れ」です。
使ってはいけない場合
- プロトタイプの探索段階。 何を作るのか自体が未定なら、状態の構造を固定することが邪魔になります。
- 状態が 1 つしかないコンポーネント。 boolean 1 つで済むものを union で包むと、読みにくくなるだけです。判定基準は「メンバーごとに付随するデータが違うか」です。
- ライブラリなしで作る素のフォーム。 フィールドの値とエラーしかないフォームに 3 段目の union をかぶせるのは過剰設計です。
よくある間違い
- 導出できる値を状態として保存する。 一番よくあって、一番静かな失敗です。見分け方は「この値を消しても、他の値から計算できますか?」
- クエリの状態をローカルのマシンにコピーする。 1 レンダー遅れてついてくる 2 つ目の真実ができます。
- state の union に action を入れる。 stale closure と偽の action が同時に生まれます。
- 複数の boolean で 1 つの流れを表現する。
isLoading+isErrorの組み合わせは、「ローディング中かつエラー」という不可能な状態を型の上で許してしまいます。statusリテラル 1 つに置き換えます。 - 自分のフィールドがないのにタグ付きオブジェクトで包む。
{ kind: 'paid' }で包むと、呼び出し側ごとに.kindを剥がすコストが増えるだけで、新しく防げる間違ったコードはありません。 - 最初の取得のローディング・エラーをコンポーネントの中で分岐する。 境界に持ち上げられるのに持ち上げていないなら、同じ分岐がコンポーネントごとに複製されます。
関連概念
- exhaustiveness-enforcement — 作った union のすべてのケースを処理したかをコンパイラに確認させる
- false-type-contracts — 型がランタイムより強く約束してしまうのを避ける
- react-query-invalidate-vs-staletime — 2 段目で消費するクエリ状態の更新ポリシー
- typescript-environment-contract — これらのルールが実際に強制されるために必要なコンパイラ設定