🚅

ReactとXStateを使った堅牢な状態設計

2023/05/21に公開

この記事から得られること

  • Elmで推奨されている「あり得ない状態を作らない("Making Impossible States Impossible")」というコンセプトは、Reactのコンポーネント設計にも適用できる。
  • 有限ステートマシン(Finite State Machine, FSM)の考え方は、システムの状態と遷移を明確に定義することで、バグを防ぐことができる。
  • XStateは、Reactと組み合わせて使える状態管理ライブラリであり、有限ステートマシンの概念を導入することができる。
  • XStateを使ったコードのサンプル。
  • Reduxもステートマシンの概念を採用しているが、XStateの方が状態遷移のビジュアライゼーションや有限ステートマシンの概念の活用において優れている。

背景

Elmで推奨されている「あり得ない状態を作らない("Making Impossible States Impossible")」というコンセプトについて思い出しました。それがきっかけで、この設計思想をReactのコンポーネント設計にも適用できるのかを考え、実践してみました。

人間の"状態管理能力"には限界がある

なぜ「あり得ない状態を作らない」ことが大切なのか?それは、ありえない状態の発生が要因となりバグを生む可能性があるからです。では、「あり得ない状態にならないように、注意深く実装すればいいじゃないか!」となりますが、人間の記憶や注意力に頼ることになってしまいます。その結果、全ての状態遷移を脳内で処理しようとすると、ミスが起こりやすくなります。
そうしたミスが原因で、想定していなかった状態やその遷移が起こり、バグが発生ます。(自身の開発経験の中でもこうしたことは多々ありました...😭)

例えば、以下のような実装は、あり得ない状態を容易に作り、バグを生み出してしまいます。

const Menu = () => {
  const [isOpen, setIsOpen] = useState(false);
  const [isDisabled, setIsDisabled] = useState(false);

  const toggleMenu = () => {
    if (isDisabled) return;

    setIsOpen(!isOpen);
  };

  const disableMenu = () => {
    setIsDisabled(true);
  };

  const enableMenu = () => {
    setIsDisabled(false);
  };

  return (
    <div>
      <button onClick={toggleMenu}>{isOpen ? 'Close' : 'Open'} Menu</button>
      <button onClick={disableMenu}>Disable Menu</button>
      <button onClick={enableMenu}>Enable Menu</button>
    </div>
  );
}

このコードでは、「開かれているが無効化(disalbed)されているメニュー」や「閉じられているが無効化(disabled)されているメニュー」など、意図しない状態が発生可能です。

例えば、メニューが開かれている(isOpen = true)が、無効化されている(isDisabled = true)状態なので、「Open Menu」ボタンが押されると、期待した挙動(何も起きない)と異なり、メニューは閉じる(isOpen = false)ようになります。

正直これくらいの実装であれば、それぞれのアクションに応じて状態変更を適切に行えば良いですが、手動で状態を管理すると、人間がすべての状態とその遷移を適切に処理する責任が生じます。
(余談ですが、状態を表現するためにbooleanが複数個出てくると危険な香りがしてきます。)

有限ステートマシン(FSM)とは?

解決策の一つとして、有限ステートマシン(Finite State Machine, FSM)の考え方があります。FSMは、システムの状態とそれらの間の遷移を明確に定義します。 これにより、システムがどの状態からどの状態へ遷移できるか、といった"ふるまい"を厳密に制御することできます。

生活の中の有限ステートマシンの例

自動販売機🥤は一般的な有限ステートマシン(FSM)の例です。自動販売機は以下の状態とアクションを持ちます。
🤖待機状態🤖:お金が投入されていないとき
↓ (お金を入れる)
💰お金受付状態💰:お金が投入され、商品が選ばれるのを待っているとき
↓ (商品を選択する)
🧃商品提供状態🧃:お金が十分に投入され、商品が選ばれ、その商品が提供されるとき
↓ (商品提供が完了する)
💸お釣り返却状態💸:選んだ商品の代金が投入した金額を下回った場合にお釣りを返却するとき

これらの状態間で移動するためには、特定の状態における、特定のアクション(お金の投入、商品の選択、お釣りの取り出し)が必要となります。
加えて、特定のアクションを行う際は、特定の状態でなければ正常に動作しません。「待機状態」の時に「商品を選択」しても、商品は出てこないですね...😓

XState』: 便利な状態管理ライブラリ

Reactと共に使うことでこの問題をより効率的に解決できるライブラリとして「XState」があります。XStateは、前述した「有限ステートマシン」のコンセプトを基に作られたライブラリで、Reactのカスタムフック、useMachineやuseActorを提供しています。これらのhooksは、ステートマシンの作成と管理を簡単にしてくれます。

利用準備

React自体については省略しますが、以下のコマンドを叩けば、XStateを利用することができます。

npm i -D xstate @xstate/react

提供されているパッケージ

XStateは様々なパッケージを提供しており、各種フレームワークや実現したい内容に合わせて使用することができます。
(以下一部)
xstate @xstate/react @xstate/vue @xstate/svelte @xstate/solid @xstate/test @xstate/inspect

XStateが利用されているプロジェクト

現在XStateが利用されているプロダクトやプロジェクトの一覧が、こちらで掲載されています。

XStateを利用したFSMの実装例

実際にXStateを利用して簡単な実装例を紹介します。
ここではオンラインにおける購入プロセスを例に考えてみます。

具体的な機能は次の通りです:

  1. ステートマシンの定義: まずはcreateMachine関数で購入プロセスのステートマシンを定義しています。このマシンは次の4つの状態から成ります: browsingreviewingpurchasingsuccess。それぞれの状態は特定のイベントに対する遷移を定義しています。

  2. 初期状態とコンテクスト: 状態マシンはbrowsingから始まり、このとき選択されたアイテムを表すcontextundefinedです。

  3. イベントと遷移: ユーザーがアイテムを選択すると、状態がreviewingに遷移し、選択されたアイテムがコンテクストに格納されます。ユーザーが購入を確定すると、状態がpurchasingに遷移します。購入が成功した場合、最終的にsuccess状態に遷移します。一方、失敗した場合、状態はreviewingに戻ります。

  4. 状態に応じた表示: 状態に応じて表示内容が変わります。たとえば、購入完了(success)状態になった時、"購入完了"と表示します。

import { useMachine } from "@xstate/react";
import { AnyEventObject, assign, createMachine } from "xstate";

const purchaseMachine = createMachine({
  id: "purchase",
  initial: "browsing",
  context: {
    item: undefined,
  },
  states: {
    browsing: {
      on: {
        SELECT_ITEM: {
          target: "reviewing",
          actions: assign({ item: (_, event: AnyEventObject) => event.item }),
        },
      },
    },
    reviewing: {
      on: {
        CONFIRM_PURCHASE: "purchasing",
        CANCEL_PURCHASE: "browsing",
      },
    },
    purchasing: {
      on: {
        SUCCESSFUL_PAYMENT: "success",
        FAILED_PAYMENT: "reviewing",
      },
    },
    success: {
      type: "final",
    },
  },
});

export const Purchase = () => {
  const [current, send] = useMachine(purchaseMachine);

  const selectItem = () => {
    send({ type: "SELECT_ITEM", item: "おにぎり" });
  };

  const confirmPurchase = () => {
    send("CONFIRM_PURCHASE");
  };

  const cancelPurchase = () => {
    send("CANCEL_PURCHASE");
  };

  const successfulPayment = () => {
    send("SUCCESSFUL_PAYMENT");
  };

  const failedPayment = () => {
    send("FAILED_PAYMENT");
  };

  return (
    <div>
      <p>{current.value.toString()}</p>
      <p>{current.context.item}</p>
      {current.matches("browsing") && (
        <button onClick={selectItem}>アイテムを選択</button>
      )}
      {current.matches("reviewing") && (
        <>
          <button onClick={confirmPurchase}>購入を確定</button>
          <button onClick={cancelPurchase}>キャンセル</button>
        </>
      )}
      {current.matches("purchasing") && (
        <>
          <button onClick={successfulPayment}>決済完了</button>
          <button onClick={failedPayment}>決済失敗</button>
        </>
      )}
      {current.matches("success") && <p>購入完了</p>}
    </div>
  );
};

ステートマシンの可視化

XStateの便利な機能の一つは、ステートマシンの可視化です。これはコードを書いている最中に特に便利で、各ステートとそれらの間の遷移を視覚的に理解することが可能になります。このビジュアル化は、ステートマシンの設計とその動作を共有、説明し、デバッグするのに非常に便利です。また、この機能はシステムの設計段階や仕様策定段階でも非常に役立ちます。可視化することで、状態と遷移の全体的な流れを理解しやすくなり、思わぬバグや問題を事前に発見することができます。

以下のようにステートマシンをネットワーク図のような形で表示することができます。これにより、一目でシステム全体の動きを把握でき、エラーの原因を特定しやすくなります。

contextについて

contextというプロパティは、現在どの状態にあるかを示すstateとは別に、ステートマシンが保持するデータや情報を保存する場所です。

contextはステート遷移の過程で更新されることがあります。例えば、ユーザーが何らかの入力を行ったときや、外部のAPIからのレスポンスを受け取ったときなどに、その結果をcontextに保存しておくことができます。また、contextの内容に基づいて、特定のステート遷移を許可したり禁止したりすることも可能です。

contextがあることで、ステートマシンはより高度な振る舞いを表現することが可能になります。具体的には、単に「どのステートにいるか」だけでなく、「そのステートに至るまでに何が起きたのか」「そのステートにおいて何が可能で何が不可能なのか」を表現することができます。

"利用できる情報"を含んだStateを生成するアイディア

色々触って見る中で、利用できる情報も制約の一部とし、情報を管理するのはどうかというアイディアが思い浮かびました。ただ、サンプルコードの実装例を見る限り、そのような実装は見当たりませんでした。XStateではcontextの使用が推奨されているように感じます。
おそらくですが、ステートマシンのステート(状態)とコンテキスト(データ)を明確に分離することで、ステートマシンの設計が簡潔になるというメリットがあるからと考えました。

ステート自体は基本的に「システムが存在できる状態」を表し、それぞれのステートがどのように遷移するかを定義します。一方、contextはそのステートで利用可能なデータや情報を保持します。このように、ステートとcontextを分けることで、どの状態においてどのデータが利用できるのか、また、どのようにステートが遷移するのかが明確になるという利点があります。

また、ステートとデータを混在させる設計は、ステートの数が多くなると管理が複雑になる可能性があります。
たとえば、ステートオブジェクトの中にデータを持たせると、そのデータの有無や内容によって新たなステートが生まれる可能性があります。これはステート爆発(state explosion)と呼ばれる問題で、ステートが複数のデータの組み合わせを表現するようになると、管理しきれないほどステートが増えてしまうことがあります。

ReduxとFSMについて

Reduxにおいては、Reducerをステートマシンの様に扱うことをスタイルガイド・ベストプラクティスの中で推奨されています。
本来的には、ReduxのReducerの中で、”現在の状態"と"アクション"の両方を加味した上で、新しい状態を決定する必要があるとされています。
先程の例と同じように、オンラインの購入プロセスをReduxで実装してみた例が以下になります。

import { createStore } from 'redux';

// Actions
const SELECT_ITEM = 'SELECT_ITEM';
const CONFIRM_PURCHASE = 'CONFIRM_PURCHASE';
const CANCEL_PURCHASE = 'CANCEL_PURCHASE';
const SUCCESSFUL_PAYMENT = 'SUCCESSFUL_PAYMENT';
const FAILED_PAYMENT = 'FAILED_PAYMENT';

// Action Creators
const selectItem = (item) => ({
  type: SELECT_ITEM,
  payload: item,
});

const confirmPurchase = () => ({
  type: CONFIRM_PURCHASE,
});

const cancelPurchase = () => ({
  type: CANCEL_PURCHASE,
});

const successfulPayment = () => ({
  type: SUCCESSFUL_PAYMENT,
});

const failedPayment = () => ({
  type: FAILED_PAYMENT,
});

// Initial State
const initialState = {
  state: 'browsing',
  item: undefined,
};

// Reducer
const purchaseReducer = (state = initialState, action) => {
  switch (state.state) {
    case 'browsing':
      switch (action.type) {
        case SELECT_ITEM:
          return {
            state: 'reviewing',
            item: action.payload,
          };
        default:
          return state;
      }
    case 'reviewing':
      switch (action.type) {
        case CONFIRM_PURCHASE:
          return { ...state, state: 'purchasing' };
        case CANCEL_PURCHASE:
          return { ...state, state: 'browsing', item: undefined };
        default:
          return state;
      }
    case 'purchasing':
      switch (action.type) {
        case SUCCESSFUL_PAYMENT:
          return { state: 'success', item: undefined };
        case FAILED_PAYMENT:
          return { ...state, state: 'reviewing' };
        default:
          return state;
      }
    case 'success':
    default:
      return state;
  }
};

// Store
const store = createStore(purchaseReducer);

// Usage
store.dispatch(selectItem('おにぎり'));
console.log(store.getState());  // { state: 'reviewing', item: 'おにぎり' }

store.dispatch(confirmPurchase());
console.log(store.getState());  // { state: 'purchasing', item: 'おにぎり' }

store.dispatch(successfulPayment());
console.log(store.getState());  // { state: 'success', item: null }

この例では、購入フローの各ステップを表現する状態('browsing'、'reviewing'、'purchasing'、'success')とその間で遷移するためのアクション(SELECT_ITEM、CONFIRM_PURCHASE、CANCEL_PURCHASE、SUCCESSFUL_PAYMENT、FAILED_PAYMENT)が定義されています。状態はReducer内のswitch文で管理され、各状態で可能なアクションに基づいて状態が更新されます。

ただし、Reduxでこのような状態遷移を管理すると、Reducerが複雑になりがちです。また、可能な状態遷移を明示的に表現することが難しく、どうしても手続き的な表現になってしまいます。
この点においては、XStateを利用する方が良いと感じます。

簡単な比較表
機能 / 特性 Redux XState
状態管理
アクションに基づく状態遷移
状態遷移のビジュアライゼーション ×
有限ステートマシン (FSM)の概念の採用 ×
非同期処理の管理
コミュニティサイズとサポート ◯ (大) △ (成長中)

最後に

ステートマシンの考え方は、「あり得ない状態を作らない("Making Impossible States Impossible")」というコンセプトに基づいた設計の鍵となると、強く感じました。このコンセプトを取り入れることで、思いがけない状態遷移によるバグを大きく減らす可能性が見えてきました。

しかし、正直なところ、ReactやReduxだけでこの考え方を直接実装しようとすると、コードがあっという間に膨れ上がり、思わぬ苦労が伴うことも現実です。XStateを使えば、シンプルな有限ステートマシンを設計し、それに従って堅固な状態管理を実現できると感じました!これにより、システムやコンポーネントの状態遷移が明確になり、思わぬバグに悩まされる時間も大幅に短縮できます。
ステートマシンの考え方を活かし、より良いソフトウェア設計に向けて挑戦してみようと思っています🔥

参考記事

  1. XStateを支える概念と実装方法について
  2. State Machines in React

Discussion