Next.jsでXStateを用いる場合の実装サンプル
Next.jsのアプリケーションに対して、XStateを用いた予約フローを追加するためのサンプルを実装したので、そのポイントをまとめます。
コードはAKIRA-MIYAKE/xstate-appで公開しています。
XStateとは
有限状態機械を扱うためのライブラリです。
JavaScriptにおける同様のライブラリは複数存在しますが、ドキュメントでも述べられている標準への準拠、TypeScriptから利用する場合の充実した型定義、@xstate/reactなどの特定のフレームワークからの利用に際してのパッケージの提供といった点が優れているように考えます。
サンプルアプリケーションの概要
- アプリケーションには
Shop
が複数存在します。 -
Shop
は複数のMenu
を持っています。 - アプリケーションの利用者は
Shop
詳細ページから、予約を開始することができます。 - 予約フローは「メニュー選択」、「日時選択」、「ユーザ情報入力」、「入力内容の確認」、「完了」のステップで構成されます。
-
Shop
詳細ページには予約フローを開始するアクションに加えて、特定のMenu
を選択して予約フローを開始するアクションが存在します。 - アプリケーションの利用者が
User
としてサインインしている場合は、「ユーザ情報入力」ステップでサインイン中User
の情報の確認を行います。 - サインインしていない状態で予約フローを開始したアプリケーションの利用者は、「ユーザ情報入力」ステップで、「email」、「name」、「phone」を入力するか、その場でサインインを行うかを選択することができます。
- 「入力内容の確認」で「submit」されたデータは検証が行われ、検証が成功した場合は「完了」に進み、失敗した場合はその内容を表示します。
- 各ステップはその入力内容を保持したまま、前のステップに戻ることができます。
サンプル実装なのでバックエンドの実装は行っていません。バックエンドとの通信が想定される箇所については、その挙動を模したフックなどで実装しています。
予約フローに関しても、「日時選択」は <input type="datetime-local">
による簡易的なもので、「submit」されたデータに対する検証も、「日時選択」で選択された値が現在時刻の24時間後よりも前である場合に失敗するというものとしています。
実装におけるポイント
ステートマシンの定義
xstate-app/src/state-machines/booking-machine/index.ts
基本的にはドキュメントの通りになりますが、以下のポリシーに基づいて定義しています。
- ステップごとに
State Node
を定義します。 - ステップの
State Node
は初期状態として「idle」、完了状態として 「complete」を持ち、利用者からの入力はそれに対応する状態で受け付けます。- 利用者からの入力を受け付けるためのデータが存在しているかや、仮に入力に必要なデータをステップに入った際にAPIから取得するといったケースが発生した場合に、記述の一貫性を保つためです。
-
Invoking Services
をインラインで定義せずに、独立した関数として定義します。-
Invoking Services
は外部の状態によってステートマシンの振る舞いが変わる唯一の項目であり、テストを容易に行えるようにするためです。
-
-
Guards (Condition Functions)
をインラインで定義せずに、独立した関数として定義します。- UI側でも同じ関数を用いて
button
の非活性化などの処理を行うためです。
- UI側でも同じ関数を用いて
Invoking Services
の1つである「submit」は、このサンプルでは内部で簡単な検証を行っているだけですが、実際にはAPIからの結果を返すものとなるでしょう。その際はステートマシン自体がReactの存在に依存しない実装とすることが好ましいと考えます。
ステートマシンとNext.jsとの統合
xstate-app/src/contexts/BookingServiceContext/index.tsx
ドキュメントを踏まえ、 useInterpret
とReactのコンテキストを用いてステートマシンのサービスを共有する形式としています。
xstate-app/src/layouts/shops/BookPageLayout/index.tsx
xstate-app/src/pages/_app.tsx
Reactのコンテキストプロバイダーの配置において、Next.jsの Per-Page Layouts
を利用しています。このテクニックを利用することで、特定の pages
だけを対象としたコンテキストを導入することが可能となり、 _app.tsx
での特定のパスに依存した状態の初期化など、煩雑な実装を避けることができます。
Next.js 13で試験的に導入された app/
の Layout
を用いれば、より簡潔な記述で実現できるようになるでしょう。
pages
におけるコンポーネントの制御
xstate-app/src/pages/shops/[shopId]/book/[[...slug]]/index.tsx
ステートマシンの各状態に応じたUIを提供するコンポーネントを定義し、 pages
コンポーネントで状態に応じてそれらのコンポーネントを出し分ける実装としています。
xstate-app/src/templates/shops/book/SelectMenuTemplate/index.tsx
状態に応じたUIコンポーネントはその特定の状態でのみで利用される前提となるため、あるはずのデータが存在しないという状態が生じた場合はエラーをthrowするようにしています。
テストなどでその箇所でエラーが生じるということは、ステートマシンのガード条件などが適切に定義できていないことを意味するため、ステートマシンの定義を見直すべきであると考えます。
このサンプルでは各ステップがそれぞれのパスを持つようにしていますが、Next.jsの [[...slug]]
を用いて1つのファイルでそれらのルーティングを受け持つようにしています。
これはステートマシンの状態を正としてルーティングに反映させる、というポリシーで実装しているためです。クエリストリングで現在のステップを表現する場合は、動的ルーティングを用いずに実現が可能でしょう。
今回の実装ではステートマシンの状態が変更された際に、URLを router.replace
で書き換えています。これは router.push
を用いるとブラウザバックなどによるURLの変更をステートマシンに反映する必要が発生するため、それを避けるためです。
想定される要件として、入力データのローカルへの保存、リロードやURLへの直アクセス時の復帰が考えられます。
URLがステートマシンの状態が完全に依存しているのであれば、SessionStorageなどから取得したデータをステートマシンへ反映する単純な実装で実現できますが、一時的に状態とURLの依存関係が逆転するタイミングがあると、それらのハンドリングが複雑になることが予測されます。
ステップに応じてパスを分ける目的、ブラウザバックなどの発生時や入力データのローカルへの保存と復帰時にどのような振る舞いをすべきかなどを検討することが必要でしょう。
Discussion