🎛️

Next.jsでXStateを用いる場合の実装サンプル

2022/11/06に公開

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 の非活性化などの処理を行うためです。

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