🤖

XState で複雑な状態遷移をシンプルに管理する

2024/06/13に公開

こんにちは!
KANNA の開発のお手伝いをしております、フリーランスエンジニアの len_prog です。
今回は、XState を用いてステートマシーンを作り、アプリケーションの複雑な状態遷移をシンプルに実装する方法についてご紹介します。

背景

アルダグラムでは、デジタル帳票アプリケーション「KANNA レポート」を提供しています。
KANNA レポートは、Excel や Google スプレッドシートのようなユーザーインターフェースを備えた Next.js 製の Web アプリケーションとなっており、複雑な状態遷移を多く含んでいます。

このような複雑な状態遷移を管理するためには、React アプリケーションでは通常、Redux や useReducer を用いてステートマシンを構築することが多いと思います。
しかし、これらの方法は状態遷移に厳格な制約が必要な場面で手続き的なコードを多く書くことを要求し、開発や保守・改修の難易度を上げることがあります。

元々 KANNA レポートでも最初は useReducer を使用してステートマシーンを作っていたのですが、上記の課題に直面し、他に良い解決策は無いのかと考えた結果、XState での状態管理に辿り着きました。

XState とは

XState は、JavaScript / TypeScript で有限ステートマシン(有限オートマトンなどとも呼ばれます)を作成できるライブラリです。

なお、有限ステートマシンとは、有限個の状態を持ち、それらの間の遷移を表現したモデルです。(詳しくは Wikipedia などをご覧ください)
ドキュメント上では、以下のような状態遷移図を用いて表現されることが多いです。
例えば、信号機の有限ステートマシーンを状態遷移図で書き起こすと以下のようになります。

XState の良いところ

XState を使ってみて良いと思った部分は色々ありますが、その中でも特に以下の2点が優れていると思いました。

状態遷移を宣言的に定義できる

以下のように、どんな状態があって、何が起きたときにどの状態に遷移するかを宣言的に定義することができます。(以下は、先程状態遷移図で表した信号機の動作を XState で表したものです)
なお、コードの詳細については以降のセクションで説明しますので、この段階では雰囲気だけご確認いただければと思います。

import { createMachine } from 'xstate';

const trafficLightMachine = createMachine({
  id: 'trafficLight',
  initial: 'red',
  states: {
    red: {
      on: { 
        TIMER_EXPIRES: 'green',  // 時間経過で緑に遷移
        PEDESTRIAN_BUTTON_PRESSED: 'green'  // 歩行者ボタン押下で緑に遷移
      }
    },
    green: {
      on: { TIMER_EXPIRES: 'yellow' }  // 時間経過で黄に遷移
    },
    yellow: {
      on: { TIMER_EXPIRES: 'red' }  // 時間経過で赤に遷移
    }
  }
});

なお、上記のコードと同じ内容を useReducer で書くと以下のようになります。
今回は例が単純なのでそこまでコード量などは変わりませんが、状態遷移の条件が複雑になったときにif文の中身が読みづらくなったりと、拡張性に少々問題があります。
また、ぱっと見たときにどの状態からどの状態に遷移できるのかを理解しにくいという問題もあります。

import React, { useReducer } from 'react';

const TIMER_EXPIRES = 'TIMER_EXPIRES';
const PEDESTRIAN_BUTTON_PRESSED = 'PEDESTRIAN_BUTTON_PRESSED';

const initialState = {
  state: 'red'
};

function trafficLightReducer(state, action) {
  switch (state.state) {
    case 'red':
      if (action.type === TIMER_EXPIRES || action.type === PEDESTRIAN_BUTTON_PRESSED) {
        return { state: 'green' };
      }
      break;
    case 'green':
      if (action.type === TIMER_EXPIRES) {
        return { state: 'yellow' };
      }
      break;
    case 'yellow':
      if (action.type === TIMER_EXPIRES) {
        return { state: 'red' };
      }
      break;
    default:
      return state;
  }
}

2.ブラウザ上でグラフィカルにステートマシーンを表示してデバッグができる

以下の動画のように、ブラウザ上でステートマシーンを表示しながら現在のステートを確認したり、どの状態遷移がいつ行われたかなどを確認することができます。
これは明確に他の手法より優れている部分だと私は思っていて、これができるだけでも XState を導入する価値があると思います。

実践: XState を使った承認フローの実装

ここまで XState の概要や良いところについて見てきました。
ここからは、実際に XState を用いてステートマシーンを作る方法をハンズオン形式で説明します。
題材を何にするか少し悩みましたが、紹介するのに適度な複雑さを持つ例として、承認フローを取り上げようと思います。

ステップ1: 承認フローの要件定義

まず、実装の前に承認プロセスに必要な各ステップとその条件を定義します。
一般的な承認フローでは、以下のようなステートが考えられます。

  • 初期状態(ドラフト): ドキュメントが作成されたがまだ提出されていない状態。
  • 提出待ち(承認者によるレビュー待ち): ドキュメントが提出され、承認者のレビューを待っている状態。
  • 承認済み: ドキュメントが全ての必要な承認を得て、承認プロセスが完了した状態。
  • 差し戻し(再編集要): ドキュメントに問題があった場合に、作成者が再編集を行う必要がある状態。
  • 取消し: 承認プロセスが中断され、ドキュメントが無効となる状態。

また、各状態間の遷移には以下のパターンが考えられます。

  • ドラフト -> 提出待ち: 作成者がドキュメントを完成させ、提出する操作を行う。
  • 提出待ち -> 承認済み: 承認者がドキュメントをレビューし、問題がなければ承認する。
  • 提出待ち -> 差し戻し: 承認者がドキュメントをレビューし、問題がある場合に差し戻しを行う。
  • 差し戻し -> 提出待ち: 作成者が指摘された問題を修正し、再度提出する。
  • 任意のステート -> 取消し: 特定の条件下で、ドキュメントを無効にする操作が行われる。

ステップ2: ステートマシーンの設計

ステップ1で行った要件定義を元に、ステートマシーンの状態遷移図を作成します。

ステップ3: XState での実装

ステップ2で作成した状態遷移図を元に、XState のコードを書きます。
なお、ここから実装していくコードの完成形を以下のリポジトリに用意しましたので、必要に応じて clone してください。

https://github.com/h-tachikawa/xstate-with-react

1. React アプリケーションの作成

Vite を使って React のアプリケーションを作成します。

$ npm create vite@latest xstate-with-react -- --template react-ts


> npx
> create-vite xstate-with-react --template react-ts


Scaffolding project in /Users/h-tachikawa/dev/sandbox/xstate-with-react...

Done. Now run:

  cd xstate-with-react
  npm install
  npm run dev

アプリケーションが作成できたら、指示通りアプリケーションのディレクトリに移動し、依存関係をインストールしましょう。

$ cd xstate-with-react
$ npm install

2. ライブラリのインストール

XState を使用するのに必要なライブラリをインストールします。

$ npm install xstate @xstate/react @statelyai/inspect

3. ステートマシーンの実装

以下のコマンドを実行して、ステートマシーンのファイルを作成します。

$ touch src/approvalFlowMachine.ts

ファイルが作成できたら任意のエディタで開いて、以下のコードをペーストしてください。

approvalFlowMachine.ts
import { setup, assign } from 'xstate'

type ApprovalFlowContext = {
  applicationContent?: string // 申請内容
}

type SubmitEvent = { type: 'submit'; content: string } // 作成者が提出。申請内容を含む。
type ApprovalEvent = { type: 'approve' } // 承認者が承認
type RejectEvent = { type: 'reject' } // 承認者が差し戻し
type ResubmitEvent = { type: 'resubmit'; content: string } // 作成者が再提出
type CancelEvent = { type: 'cancel' } // 取消操作

type ApprovalFlowEvent =
  | SubmitEvent
  | ApprovalEvent
  | RejectEvent
  | ResubmitEvent
  | CancelEvent

// 現状は assign 関数のジェネリクスに型引数を5つ渡さないとちゃんと型補完が効かなさそうです。
const submitAction = assign<
  ApprovalFlowContext,
  SubmitEvent,
  any,
  ApprovalFlowEvent,
  any
>({
  applicationContent: ({ context, event }) => event.content,
})

const resubmitAction = assign<
  ApprovalFlowContext,
  ResubmitEvent,
  any,
  ApprovalFlowEvent,
  any
>({
  applicationContent: ({ context, event }) => event.content,
})

const approvalFlowMachine = setup({
  types: {
    context: {} as ApprovalFlowContext,
    events: {} as ApprovalFlowEvent,
  },
}).createMachine({
  id: 'documentMachine',
  initial: 'draft',
  context: {
    applicationContent: '',
  },
  states: {
    /**
     * 例えば draft 状態のときに submit イベントが発生した場合、pending 状態に遷移し、
     * 状態遷移の際に submitAction 関数を実行する、という読み方ができます。
     **/
    draft: {
      on: {
        submit: {
          target: 'pending',
          actions: [submitAction],
        },
        cancel: { target: 'canceled' },
      },
    },
    pending: {
      on: {
        approve: { target: 'approved' },
        reject: { target: 'rejected' },
        cancel: { target: 'canceled' },
      },
    },
    rejected: {
      on: {
        resubmit: { target: 'pending', actions: [resubmitAction] },
        cancel: { target: 'canceled' },
      },
    },
    approved: {
      on: {
        cancel: { target: 'canceled' },
      },
    },
    canceled: {
      type: 'final',
    },
  },
})

export { approvalFlowMachine }

4. アプリケーションとステートマシーンを接続する

作成したステートマシーンを React アプリケーションと接続して、操作できるようにします。
src/App.tsx / src/App.css に以下のコードをペーストしてください。

App.tsx
import React, { useState } from 'react'
import { useMachine } from '@xstate/react'
import { createBrowserInspector } from '@statelyai/inspect'
import { approvalFlowMachine } from './approvalFlowMachine'
import './App.css'

const { inspect } = createBrowserInspector()

const App: React.FC = () => {
  const [current, send] = useMachine(approvalFlowMachine, { inspect }) // 第二引数に inspect 関数を渡すことで、ブラウザ上でステートマシーンを GUI で表示できます。
  const [inputContent, setInputContent] = useState<string>('')

  const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = (
    event,
  ) => {
    setInputContent(event.target.value)
  }

  const handleSubmit = (): void => {
    send({ type: 'submit', content: inputContent })
    setInputContent('')
  }

  const handleApprove = (): void => {
    send({
      type: 'approve',
    })
  }

  const handleReject = (): void => {
    send({
      type: 'reject',
    })
  }

  const handleResubmit = (): void => {
    send({
      type: 'resubmit',
      content: inputContent,
    })
    setInputContent('') // 入力をクリア
  }

  const handleCancel = (): void => {
    send({
      type: 'cancel',
    })
    setInputContent('') // 入力をクリア
  }

  return (
    <div className='container'>
      <h2>申請フロー管理</h2>
      <p>現在の状態: {current.value}</p>
      // ステートごとにパターンマッチのような形で書ける!
      {current.matches('draft') && (
        <>
          <input
            type='text'
            value={inputContent}
            onChange={handleInputChange}
          />
          <button onClick={handleSubmit}>提出</button>
        </>
      )}
      {current.matches('pending') && (
        <>
          <p>申請内容: {current.context.applicationContent}</p>
          <button onClick={handleApprove}>承認</button>
          <button onClick={handleReject}>差し戻し</button>
        </>
      )}
      {current.matches('rejected') && (
        <>
          <input
            type='text'
            value={inputContent}
            onChange={handleInputChange}
          />
          <button onClick={handleResubmit}>再提出</button>
        </>
      )}
      <button onClick={handleCancel}>取消</button>
    </div>
  )
}

export default App

App.css
.container {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
  background-color: #f4f4f4;
  border-radius: 8px;
  box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}

h2 {
  color: #333;
  text-align: center;
}

button {
  background-color: #4CAF50;
  color: white;
  border: none;
  border-radius: 4px;
  padding: 10px 20px;
  margin: 10px 5px;
  cursor: pointer;
  font-size: 16px;
}

button:hover {
  opacity: 0.9;
}

input[type="text"] {
  width: calc(100% - 22px);
  padding: 10px;
  margin: 10px 0;
  border: 1px solid #ccc;
  border-radius: 4px;
}

ステップ4: 実際に動かしてみる

さて、これでアプリケーションを動かす準備が整いました。
以下のコマンドを実行して、http://localhost:5173 をブラウザで開いてください。

$ npm run dev

VITE v5.2.13  ready in 166 ms

➜  Local:   http://localhost:5173/
➜  Network: use --host to expose
➜  press h + enter to show help

ブラウザで上記ページを開くと、アプリケーションが立ち上がると同時に、gif で右側に表示されている Web サイトが別のタブで開かれたと思います。
この Web サイトは、ステートマシーンの現在の状態を確認したりできるものとなっております。
この状態で実際にアプリケーションを触ってみると、ステートが変わる様子がリアルタイムに GUI で分かりやすく見られます。

まとめ

XStateを使うことで、承認フローの状態管理がシンプルに行えるようになります。
これにより、プロセスの見通しを良くし、エラーの発生を抑えながら、開発の効率化が図れます。
この記事が複雑な状態管理に悩む方にとって、少しでも参考になれば幸いです!

もっとアルダグラムエンジニア組織を知りたい人は、ぜひ下記の情報をチェックしてみてください!

GitHubで編集を提案
アルダグラム Tech Blog

Discussion