State Machine は以前から知ってはいたけど実際に書いて動かしてこれはよいなと思ったのでそこで感じたことなどを残しておこうと思う。
以下は Vue.js と XState ( SCXML ) の言葉で書くけど、考え方は他のツールでも同じように使えるはず。
課題感
- View の状態を data ( state ), props で引き回して管理するのは繊細だし、状態の変数と実際に表示に使う変数が分かれたりしてこれらの変数は増えがち
- 変数が直接 data ( state ) にあると jQuery 的にゴリゴリ data を書き換える誘惑に勝つのは難しく、Reactive ではなくなりやすい
- この data を書き換えた影響をちゃんとハンドリングする component の実装が揃わないと UI のコーディングをブロックしてしまう
- View component はそもそも複雑なオブジェクトでツリーの親子関係など暗黙的な依存もあり、テストコードを書くのが億劫になりやすい
- では Store かというと変数が増えて繊細という問題は解消しないし、オブジェクトの Life Cycle への対応を自前で実装しないと無限に state が増える危険性もある
StateMachineとは何か
StateMachine は状態のパターンとその遷移に繋がるイベント、条件、ロジックのモデルであり、この State Machine を実現するライブラリはいわゆる DSL である。ストレージや何らかのフレームワークには依存しない。
とりあえず WikiPedia 貼っとく。
SdlStateMachine - 有限オートマトン - Wikipedia
いったんふーんと思ってもらえればおっけー。
StateMachineで何を解決できるか
例えば複数の状態を持ち、ユーザーの入力(イベント)に対して状態ごとに反応を変える、といった処理を作るとする。
フォームの送信なら 1) 送信可能な状態、2) 送信中の状態、3) 送信完了の状態があり、送信の操作に反応するのは 1 の状態の時だけである。2 と 3 では送信の操作に反応してはいけない。
これの実装は最も素朴には送信 handler の中で状態を見て分岐する形になるだろう。条件や状態が増えたらこの if 文がどんどん複雑怪奇になっていくパターンだ。
もう一つは 2 の状態になったら event listener を無効化する、あるいは event listener をセットしていない component にまるごと切り替えてしまう方法。こうすれば handler の中で if で分岐する必要はなくなる。状態と component が 1:1 になれば複雑さを解消できる。
では、表現は変わらないが内部の状態が変わるものはどうなるか。送信中の状態で送信ボタンの表現を切り替えずに別なところにインジケータのようなものを表示するような UI もあり得る。その場合は component の切り替えはできないので、click イベントを拾ったら handler で条件分岐する必要が出てくる。1
StateMachine を使えばこういう UI の表現やそれを実現するための component の階層に依存せずに状態を定義し、反応するアクション(イベント)とそれに対応する遷移先の状態を記述することができる。状態ごとに有効な入力をあらかじめ定義するので、入力を受け取って分岐する処理で頭を悩ませる必要がない。
そして状態の管理を component から StateMachine に移譲してしまうので、それぞれの状態や遷移時に行う処理が増えても、状態の数が変わらないのであれば View component 側で対応しなければいけないことは増えない2。例えばフォームの送信処理の際に storage に何かを記録するとか、同時に Google Analytics のようなものに何かを送信するとか。そういった変更が入っても component 側は頑張る必要がない。
まとめると、以下のようになる。
View component 側から見ると、具体的で詳細な処理や、依存する component の実装が進まなくても StateMachine があれば UI を組むことができ、処理の詳細に変更が入っても気にする必要がない。
StateMachine 側から見ても同じことが言える。component の設計が決まらなくても component に依存せず StateMachine だけを先行してテスト、実装できる。また状態に名前を付けなければいけないので共通認識を作りやすく、テストコードも書きやすくなる。
またあえて上では書かなかったが、タイマーの絡む条件があったりそのタイマーの処理をキャンセルするなどが入ると、生々しく書くのは煩雑でバグが入り込みやすいので StateMachine 自体が持っているタイマー関係の機能に依存してしまう方がよいと思う。
StateMachineでは何を解決できないか
StateMachine はあくまで状態の定義を行うためのもので、そこに State 以外のストレージ読み書きや通信などの処理は入れにくいし、入れない方がよい。
つまり、StateMachine を追加しつつ component を具体的な処理から独立した単なる Reactive な UI のままにするためには、
- View component
- StateMachine
- 1 と 2 を繋ぐ人
- より詳細な処理(より複雑なロジックや storage や network の I/O を担う人)
が必要になる。特に 3 の繋ぐ人が重要になる3。
StateMachine を入れることで UI の実装自体を早くすることはできるが、一方で見て分かる通り 登場するクラスやオブジェクトは増え、それぞれのテストなどのコストは減らない。あくまでお互いにブロックしにくくなるというだけで、トータルのコストが下がるかどうかは一概に言えない。
個人的には View component か Store の二択で頑張るよりははるかにテストしやすかったので、コードを読み書きするコストはともかく、手動で確認しなくてもだいぶ安心して進めることができたという意味では、心理的なコストはだいぶ下がっていると思う。なんだけど、目に見えるコスト(実装完了までの工数)だけが最優先課題であるならそれを StateMachine で解決することはできないかもしれない。
また View Framework に限った話にはなるが、component が消えると StateMachine が消えてしまうので、どこに紐づけるべきかは考える必要はある。
まとめ
View component や Store だけで頑張るのはやめて、適切に Plain Old Object を使うレイヤーを差し込むとよいぞ。UI の表現の都合からくる component 階層の変更に強くなる。たぶんこれを Interactor と呼ぶとよいはずだ。
StateMachine は上の考え方の一例として分かりやすく効果的。特に状態が3つ以上あるとかタイマーが絡む場合は非常に管理しやすくなる。
State に応じた詳細で複雑な処理(外部 API を叩くとか)がある場合はそこも分けてしまおう。そいつは周辺の詳細であり、component とか store と一緒に最初に挙げた Interactor に DI しよう。Clean Architecture だ。
おまけ
今回具体的には
- Vue.js
- XState - JavaScript State Machines and Statecharts
- reduxjs/redux-devtools: DevTools for Redux with hot reloading, action replay, and customizable UI
を使って UI の実装とは独立して並行で StateMachine から TDD で作ったんだけど、控えめに言って最高に快適だった。XState の書き方に慣れるまでがいちばん時間掛かったけど、そこは次回以降に期待。