ステートマシン

有限個の状態と遷移で振る舞いを定義する計算モデルで、ワークフローや UI の状態管理に使う

設計アルゴリズム

ステートマシンとは

ステートマシン (有限状態機械, FSM) は、有限個の状態と、イベントによる状態遷移で振る舞いを定義する計算モデルである。ワークフロー、UI の状態管理、プロトコル設計に使われる。

要点は、次にどうなるかが「今どの状態にいるか」と「どのイベントを受け取ったか」の 2 つだけで決まり、そこに至った履歴を参照しないことである。この割り切りがあるおかげで、起こりうる振る舞いの全体を状態とイベントの表として書き出せる。

真偽値のフラグを並べて状態を表す実装と比べると差が分かりやすい。isPaid / isShipped / isCancelled の 3 つで管理すると組み合わせは 8 通りできるが、業務上ありえるのはそのうち数通りで、「発送済みなのに未払い」のような組み合わせを禁止する仕組みがどこにもない。状態を 1 つの値にまとめてしまえば、ありえない組み合わせは表現そのものが不可能になる。

基本要素

次の 5 つが揃うとステートマシンが定まる。

要素説明
状態 (State)システムが取りうる状態
イベント (Event)状態遷移のトリガー
遷移 (Transition)イベントによる状態の変化
初期状態開始時の状態
最終状態そこから出る遷移を持たない状態

設計上の分かれ道は、表に載っていない状態とイベントの組み合わせをどう扱うかである。エラーにすれば仕様外の操作をその場で発見できるが、利用者の二重クリックや再送のように何度も届くイベントは、同じ状態に留まる遷移として明示的に受け止めるか、無視すると決めておかないと例外が頻発する。

身近な最小の例が Promise で、pending から fulfilled か rejected へ一度だけ遷移し、決着した後はどのイベントでも動かない (Promise / async-await)。状態が 3 つしかなくても、遷移が一方向であるという性質だけで「二度解決されない」という保証が得られる。

注文のステートマシン

業務ルールを図に落とすと、書かれていない矢印がそのまま禁止事項になる。

[pending] --pay--> [paid] --ship--> [shipped] --deliver--> [delivered]
    |                 |
    +--cancel-->  [cancelled]
    |
    +--expire-->  [expired]

cancelledexpireddelivered からは矢印が出ていない。これが最終状態であり、配送済みの注文をキャンセルしたい場合は取り消しではなく返品という別の遷移を新たに定義する、という設計判断が図の形で残る。キャンセルできるのが支払い前と支払い後だけで発送後は不可、という線引きも矢印の有無で読み取れる。

TypeScript での実装

遷移をデータとして持つと、上の図と実装が 1 対 1 で対応する。if の分岐で書くと同じ判定が複数の関数に散り、図を更新しても実装のどこを直すべきか追えなくなる。

type OrderState = 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled' | 'expired';
type OrderEvent = 'pay' | 'ship' | 'deliver' | 'cancel' | 'expire';

const transitions: Record<OrderState, Partial<Record<OrderEvent, OrderState>>> = {
  pending: { pay: 'paid', cancel: 'cancelled', expire: 'expired' },
  paid: { ship: 'shipped', cancel: 'cancelled' },
  shipped: { deliver: 'delivered' },
  delivered: {},
  cancelled: {},
  expired: {},
};

function transition(state: OrderState, event: OrderEvent): OrderState {
  const next = transitions[state]?.[event];
  if (!next) throw new Error(`Invalid: ${state} + ${event}`);
  return next;
}

外側の Record<OrderState, ...> は状態を 1 つでも書き忘れるとコンパイルエラーになるので、状態を追加したときの記述漏れは型で防げる。一方でイベント側は Partial なので、shipped に対する cancel のように定義していない組み合わせは型検査を通り抜け、上のように実行時の例外になる。組み合わせの網羅性まで型で押さえたい場合は、状態ごとに保持するデータを分けた直和型として表し、遷移関数を状態で場合分けする書き方に寄せる (代数的データ型パターンマッチング)。

メリットと落とし穴

定義されていない遷移はエラーになるため不正な状態遷移を防止できる。状態遷移図で全体を可視化でき、状態 × イベントの組み合わせで網羅的にテストしやすい。状態遷移表がそのまま仕様書として機能する。

落とし穴は 2 つある。1 つは状態の数が膨らむことで、独立した関心を 1 つの状態値に詰め込むと状態数が掛け算で増える。注文の進行状況と支払い方法の審査状況を同じ列挙型で表すと、両者の組み合わせだけ状態を並べる必要が出てくる。互いに影響しない軸は別のマシンに分けるか、階層を持つ状態として表現する。

もう 1 つは永続化した状態の同時更新である。データベースの列に現在の状態を持たせて「読んで、遷移を判定して、書き戻す」と実装すると、同じ注文に対する 2 つのリクエストが並行したときに、どちらも pending を読んで両方が遷移に成功してしまう。更新時に「現在の状態が pending である場合にだけ書き換える」という条件を付け、更新できた行数が 0 なら遷移を失敗として扱う必要がある。

AWS Step Functions

複数のサービス呼び出しにまたがる処理を、状態と遷移の定義として宣言的に書き、実行のたびに通った経路と入出力を履歴として残す仕組みが AWS Step Functions である。個々の処理を関数として書き、順序・条件分岐・再試行・待機を定義側へ寄せられる点が、アプリケーション内でステートマシンを組む場合との違いになる。詳細は Step Functions を参照。

XState (フロントエンド)

フロントエンドで状態遷移を宣言的に書くライブラリが XState である。2026 年 8 月時点のメジャーバージョンは v5 系 (v5.0.0 が 2023 年 12 月公開) で、createMachine に状態と遷移を渡す形をとる。

import { createMachine } from 'xstate';

const toggleMachine = createMachine({
  initial: 'inactive',
  states: {
    inactive: { on: { TOGGLE: 'active' } },
    active: { on: { TOGGLE: 'inactive' } },
  },
});

状態が 2 つ 3 つで遷移も単純なうちは useStateuseReducer で足りる。導入する価値が出るのは、読み込み中・成功・失敗・再試行中のように状態が増え、それぞれで実行してよい操作が違い、状態に入ったとき出たときの処理 (タイマーの開始や購読の解除) を取り違えるとバグになる段階に入ってからである。ライブラリを足す前に、まず状態を 1 つの値にまとめる書き換えだけで解ける問題かどうかを確かめるとよい。

現場での応用を知るには関連書籍も役立つ。

この記事は役に立ちましたか?

関連用語

関連する記事