EY-Office ブログ

複雑な状態遷移のあるアプリは有限状態マシンXstateだ!

リファクタリングのお仕事をしてみた(2)

そこで、チェック済み(isChecked)と問題あり(isErrorExist)をstateで持つ事にしました(このようにページの状態を持つ方式はいくつかの方式があり、より良い方法もあるかも知れません)。

と書いたのはリファクタリング結果のコードで、2つのステートをチェックして処理を進める部分がスマートではないと思ったからです。

有限状態マシン

有限状態マシン(有限オートマトン)

リファクタリングのお仕事をしてみた(2)を書いている時は思い出せなかったのですが、複雑な状態遷移を持つプログラムを書くには、有限状態マシン(有限状態機械、有限オートマトン)が役に立ちます。 JavaScriptではXstateという有名な有限状態マシン・ライブラリーがあります。しかも最近はReactのstate管理にも使われているようで、DEV CommunityのReactコミニュティーにもときどき記事が出てきます。

今回のアプリの状態遷移をUMLの状態マシン図で書くと以下のようになっています。

一方Xstateでの状態定義(state)は以下のようになります。詳細は省略しますが、

  • トップレベルのidle, checking, confirmation, creatingが状態(名)になります
  • on:onDone:は現在の状態から次に状態への遷移になります
  • 大文字で書かれたREGISTER, CONFIRM_YES, CONFIRM_NOはReactからのイベントで、このイベントが発生した時target:に遷移します
  • cond: は指定された条件が成り立つときtarget:に遷移します、成り立たないときは次の{target: ..}に移ります。
  • invoke:src:で指定された非同期処理を実行する指定です

下のコードは、上の状態マシン図を、JSON形式で表したものだと判ります。

{
  idle: {
    on: { REGISTER: 'checking'}
  },
  checking: {
    invoke: {
      id: 'checkUser',
      src: 'checkUser',
      onDone: [
        { target:'creating', cond: 'isNoError' },
        { target:'confirmation' }]
    }
  },
  confirmation: {
    on: {CONFIRM_YES: { target:'creating' },
         CONFIRM_NO:  { target:'idle' }}
  },
  creating: {
    invoke: {
      id: 'createUser',
      src: 'createUser',
      onDone: { target:'idle' }
    }
  }
}

コード全体

コード全体は以下のようになります。簡単に説明すると

  • ① Xstateの定義作成
  • context:は状態遷移とともに保持される変数です、Reactのstateとして使えます。ここでは、
    • users: 登録通信で戻るユーザー情報
    • checkResults: チェック通信の戻り情報
  • ③ 初期状態はinitial:に定義
  • ④ 上で説明した状態定義
import React from 'react'
import { createMachine } from 'xstate';
import { useMachine } from '@xstate/react'

type CheckListType = {name: string, error: string}[]
type UserListType = {name: string, email: string}[]

const registerMachine = createMachine({  // ← ①
  id: 'register',
  context: {                             // ← ②
    users: [] as UserListType,
    checkResults: [] as CheckListType
  },
  initial: 'idle',                       // ← ③
  states: {                              // ← ④
    idle: {
      on: { REGISTER: 'checking'}
    },
    checking: {
      invoke: {
        id: 'checkUser',
        src: 'checkUser',
        onDone: [
          { target:'creating', cond: 'isNoError' },
          { target:'confirmation' }]
      }
    },
    confirmation: {
      on: {CONFIRM_YES: { target:'creating' },
           CONFIRM_NO:  { target:'idle' }}
    },
    creating: {
      invoke: {
        id: 'createUser',
        src: 'createUser',
        onDone: { target:'idle' }
      }
    }
  }
},
{
  guards: {                                               // ← ⑤
    isNoError: (context, _event) => {
      return !context.checkResults.find(e => e.error)
    }
  },
  services: {                                             // ← ⑥
    checkUser: async(context, _event) => {
      const res = await fetch('/check', {method: 'POST'})
      context.checkResults = await res.json()             // ← ⑦
    },
    createUser: async(context, _event) => {
      const res = await fetch('/create', {method: 'POST'})
      context.users = await res.json()
    }
  }
})


export const App: React.FC = () => {
  const [machine, send] = useMachine(registerMachine);      // ← ⑧
  const { users } = machine.context                         // ← ⑨

  return (
    <div>
      <button onClick={() => send('REGISTER')}>登録</button> {/* ← ⑩ */}
      {machine.matches('confirmation') &&                    /* ← ⑪ */
        <div>
          問題がありますが登録しますか?
          <button onClick={() => send('CONFIRM_NO')}>キャンセル</button>
          <button onClick={() => send('CONFIRM_YES')}>OK</button>
        </div>
      }
      <ul>
        {users.map((user, ix) => <li key={ix}> {user.name} </li>)}
      </ul>
    </div>
  )
}
  • cond:で呼び出す遷移条件の定義、context上のチェック結果にエラーがあるか調べています
  • invoke:で呼び出す非同期処理の定義、ここではfetch()を使い通信をしています
  • ⑦ 通信で戻ってきた値をContextに代入しています、ドキュメントには直接代入するのではなく、assign()を使うように書かれているように思えますが上手く動作しないので、ここでは代入しています
  • ⑧ ReactでXstateを使うためのHook。戻り値は、
    • machine: 現在の状態
    • send: Xstateにイベントを送る関数
  • machine.contextで現在のContext値が取得できます
  • ⑩ 登録ボタンを押すと、REGISTERイベントがXstateに送られます
  • ⑪ 現在の状態がconfirmationなら確認メッセージ・ボタンを表示
    • machine.matches()で現在の状態をチェックできます

デバッグ

最初はかなり手間取りましたが、@xstate/inspectをインストール・設定すると下画像のような、遷移図や色々な情報のインスペクターが表示され、デバッグが楽になります。

@xstate/inspectの設定ではインスペクターは別タブに表示されますが、https://gist.github.com/drbr/c1bac785cd5e5739d264f9a2be3465d8 に、iframeを使いアプリ画面にインスペクターを表示する方法が書かれています。

まとめ

複雑な状態遷移を持つアプリを作るにはXstateは有用なライブラリーだと思います。

Xstateのメリット

  • 状態遷移がJSONで記述され、アプリの状態遷移の理解がしやすくなる
  • 状態遷移のメンテナンスが容易になる
  • 状態遷移管理のための余分なstateが不要になる
  • Reactコンポーネントで必要なstateの管理もできる
  • xstate-routerを組み合わせるとroute(URL)と関連付けもできる

Xstateのデメリット

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ