STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

XStateを支える概念と実装方法について

最初に

STORES 予約 の開発をしているTak-Iwamotoです。

STORES 予約 は元々 Coubic というサービス名でリリースされ、heyにジョインしたタイミングで STORES 予約 としてリブランディングされました。

ただ、今でもエンドユーザーさまが予約する画面は Coubic の名称となっています。

この Coubic はNext.jsで作られており状態管理のライブラリはReduxを使用しているのですが、良くないReduxの使い方によって改修に時間がかかったり、コードの見通しが悪い箇所があります。

詳しくはこちらの記事をご覧ください。

そういった背景があり、状態管理のリファクタリングの一環でRedux -> XStateへの移行を検討中です。(ただ、優先順位の都合でまだ本格的に移行はできていません。)

この記事ではXStateのコアとなる概念、Coubicで抱えているReduxの課題、XStateによる状態管理の実装やAPIの解説などを書かせていただきます。

XStateとは

XStateはステートマシンを実装するためのJavaScriptライブラリです。

有名なプロダクトだとAWS AmplifyHashicorp Vaultで使用されているそうです。参照

ではステートマシンを使用したい場面というのはどういったケースが考えられるでしょうか。

それは一連の状態変化を制御したい場合です。

例えば信号機を考えてみると、青 -> 黄 -> 赤 -> 青といったように状態が変化する順番は決まっています。赤 -> 黄 や 青 -> 赤と変化することは許容されていません。

実際のアプリケーションでも決まった流れで状態変化を制御したいケースはあります。

例えば、 STORES 予約 だと予約する前に予約する日時を選択し、顧客情報を入力、支払い方法を選択、確認画面、予約確定画面といったように表示する画面の順番は決まっています。 f:id:Tak-Iwamoto:20220413095021p:plain

確認画面なしで確定画面に遷移したり、予約日時を選択せずに予約確定画面となるのはアプリケーションとして機能していません。

他にも例えばフリマアプリだと、販売中 -> 購入済み -> 支払い済み -> 配送中 -> 到着 -> 評価して取引完了といった一連の状態遷移があるでしょう。

こういった例のようにアプリケーションにはさまざまな場面で状態遷移を考慮するケースが存在しています。

Coubicで経験したReduxのアンチパターン

Coubicでは先ほど例に示した予約の入力画面でステートマシン的な用途でReduxを使用しています。

一方でReduxを用いた実装の中にはいくつかアンチパターンを踏んでいるものがあります。

その中の一つに状態を更新する際にバリデーションしていないということがあります。

つまり、Actionをdispatchして状態を更新する際に特にバリデーションせずに、ただdispatchされたActionを元に状態を書き換えているケースが存在しています。

これはバグを発生させる可能性があります。

例えば、支払い方法を選択する前に確定画面に遷移するActionをdispatchし、何もバリデーションしていないと確定画面に遷移できてしまいます。

これだと支払い方法を選択していないので、予約の処理に失敗してエラーとなります。

信号機で例えると、赤色の状態で黄色に更新するActionをdispatchすると黄色に変化してしまうようなものです。

正しく状態を更新するためには現在の状態とdispatchされたActionを元に次の状態を算出するべきですが、現状ではバリデーションを十分行えていない箇所があります。

(また自分の経験上、Reduxを使用しているシステムで正しく状態遷移のバリデーションが行われているものをあまり見たことがありません...)

XStateの利点

Reduxのドキュメントを見てみると、Reducerをステートマシンとして扱うべきと記載されています。

Reduxでステートマシンを実装する場合、dispatchされたActionで状態を変化させるかどうかの制御は開発者が意識して実装する必要があります。

つまり状態遷移を行うかどうかのバリデーションは開発者自身が手続き的なコードを書いて対応する必要があります。

かんたんな状態遷移であれば特に問題にはなりませんが、状態遷移の条件が複雑になり、あるステートが別のステートに依存したりすると手続き的なコードで都度実装していると、見通しが悪くなる可能性があります。

XStateはステートマシンを実装するのに特化したライブラリで、こうしたステートマシンを実装する上で面倒な実装をXStateがカバーしてくれます。

createMachineAPIを使ってステートマシンのオブジェクトを作成し、とりうる状態遷移を宣言的に定義するだけで容易にアプリケーションの状態遷移を実装することができます。

細かい状態遷移の制御はXStateに任せて、開発者はアプリケーションの状態遷移の設計に注力できる点がXStateを使う大きな利点だと自分は考えています。

XStateの概念

XStateを理解する上で重要な概念として以下の3つがあります。

有限ステートマシン

有限ステートマシンはシステムの振る舞いを以下の5点で説明される有限の状態とその遷移の組み合わせで抽象化したモデルです。

  • 有限の状態
  • 有限のイベント
  • 初期状態
  • 次の状態を現在の状態とイベントから計算する関数
  • 最終状態

先ほどReduxの例で説明したReducerをステートマシンとして扱うべきというのは、Reducerを次の状態を現在の状態とイベントから計算する関数にするべきということだとわかります。

Statecharts

以下のような要素を導入して有限ステートマシンを拡張したものです。XStateのAPIとも対応しているので後ほど説明します。

  • Guarded transitions
  • Actions (entry, exit, transition)
  • Extended state (context)
  • Orthogonal (parallel) states
  • Hierarchical (nested) states
  • History

アクターモデル

非同期処理や分散処理を行うためのモデルです。

代表的な実装ではAkkaが有名です。

アクターは次の特徴を持ちます。

  • メッセージを受け取る
  • 他のアクターもしくは自分自身にメッセージを送信する。
  • メッセージを受け取った際に次のいずれか処理を実行する。
    • 自分自身の状態を更新する
    • 他のアクターにメッセージを送信する
    • あたらしいアクターを生成する。

PubSubを使ったマイクロサービスのアーキテクチャと共通している特徴もあり、分散システムや非同期処理に適しているモデルです。

XStateではステートマシンをアクターとして扱います。例えば、1つしかステートマシン(アクター)がなくても、その状態を更新する際は自分自身のステートマシンにメッセージを送信することで状態を更新します。

こちらも後ほどAPIを説明する際に振り返って説明します。

XState + Reactのデモ

ではここからは実際のReactのコードでXStateを使用した例を元に説明します。

全体のコードはTak-Iwamoto/xstate-demoにあげているので、詳細はこちらでご確認ください。

package.jsonは次のとおりです。reactとxstate, @xstate/reactを使用します。

{
  "scripts": {
    "dev": "next",
    "build": "next build",
    "start": "next start",
    "type-check": "tsc"
  },
  "dependencies": {
    "@xstate/react": "^1.6.3",
    "next": "latest",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "xstate": "^4.26.1"
  },
  "devDependencies": {
    "@types/node": "^12.12.21",
    "@types/react": "^17.0.2",
    "@types/react-dom": "^17.0.1",
    "typescript": "4.5"
  }
}

かんたんなプロトタイプとして次の仕様のコードを実装します。

  • 最初に予約日時を入力する。
  • 顧客情報として名前と年齢を入力する。
  • 決済するカードブランドを入力する。
  • 入力した内容が確認画面に表示される。
  • 確認画面でsubmitすると予約が完了する。

この仕様を満たすステートマシンを定義します。

まず、ステートマシン全体をとおして保持する値を定義します。

これは先ほど説明したStateschartのExtended Stateと対応しており、ステートマシンの全ての状態で参照、更新できる値です。

今回の例では入力情報を保持する役割として使用します。

XStateではContextとしてAPIが定義されているので、予約の一連のフローに関連するContextということでReservationFlowContextとしています。

type ReservationFlowContext = {
  rsvDate: string;
  customerInfo: {
    name: string;
    age: number;
  };
  cardBrand: string;
}

では次にステートマシンにおけるイベントを定義します。

XStateではEventsAPIで定義されており、typeのプロパティを持ったオブジェクトである必要があります。

type以外にもプロパティをつけることができるため、今回は入力値を渡すvalueを定義しています。

type ReservationFlowEvent =
  | { type: "INPUT_DATE"; value: string }
  | { type: "INPUT_CUSTOMER_INFO"; value: { name: string; age: string } }
  | { type: "INPUT_PAYMENT"; value: string }
  | { type: "CONFIRM"; value?: any }
  | { type: "ERROR"; value: any };

それぞれ、日時入力、顧客情報入力、支払い方法入力、確定という今回の仕様と関連したイベントを定義しています。

ではこれらの型定義を元にステートマシンを作成します。createMachineの関数を使って次のように定義します。

import { assign, createMachine } from "xstate";

export const reservationFlowMachine = createMachine<
  ReservationFlowContext,
  ReservationFlowEvent
>(
  {
    id: "rsv-flow",
    initial: "date",
    states: {
      date: {
        on: {
          INPUT_DATE: [{ target: "customerInfo", actions: "inputDate" }],
          ERROR: "error",
        },
      },
      customerInfo: {
        on: {
          INPUT_CUSTOMER_INFO: [
            { target: "payment", actions: "inputCustomerInfo" },
          ],
          ERROR: "error",
        },
      },
      payment: {
        on: {
          INPUT_PAYMENT: [{ target: "confirm", actions: "inputCardBrand" }],
          ERROR: "error",
        },
      },
      confirm: {
        on: { CONFIRM: [{ target: "completed" }], ERROR: "error" },
      },
      completed: {
        type: "final",
      },
      error: {
        type: "final",
      },
    },
  },
  {
    actions: {
      inputDate: assign((_ctx, evt) => ({
        resvDate: evt.value,
      })),
      inputCustomerInfo: assign((_ctx, evt) => ({
        customerInfo: evt.value,
      })),
      inputCardBrand: assign((_ctx, evt) => ({
        cardBrand: evt.value,
      })),
    },
  }
);

このステートマシンを識別するidが必須なのでrsv-flowとしています。

statesにステートマシンが取り得る状態を定義していて、以下の6つがあります。

  • date
  • customerInfo
  • payment
  • confirm
  • completed
  • error

また、ステートマシンにおける初期状態を定義する必要があるため、今回はdateを初期状態にしています。

ではdateを例にして定義されているステートを見てみましょう。

{
  date: {
    on: {
      INPUT_DATE: [{ target: "customerInfo", actions: "inputDate" }],
      ERROR: "error",
    },
  },
}

onには送信されたイベントに対するアクションを定義します。

この例だと、date状態の場合はINPUT_DATEとERRORのイベントが送信された場合にのみ処理を実行し、それ以外のイベントでは処理を行いません。

そしてイベントに対応するアクションを定義しています。

先ほどアクターの項目で説明した、メッセージを受け取った際に実行する処理を定義しています。

また、StatechartsのActionsの内のtransitionでもあり、ドキュメントではTransitionsの項目で説明されています。

ERRORの例のように、単純に文字列を渡した場合はその値の状態にステートマシンを更新します。

この例だと、ERRORのイベントが送信された場合はdateからerrorに状態が変化します。

また、INPUT_DATEのようにオブジェクトの配列を渡すこともできます。

targetが次の状態を表しており、actionsがステートマシンのContextを更新する処理です。

assignが現在のContextと送信されたイベントを引数にとる関数を受け取ってContextを更新します。

inputDateだと送信されたイベントの値をContextのrsvDateに格納しています。

{
  actions: {
    inputDate: assign((_ctx, evt) => ({
      rsvDate: evt.value,
    })),
    inputCustomerInfo: assign((_ctx, evt) => ({
      customerInfo: evt.value,
    })),
    inputCardBrand: assign((_ctx, evt) => ({
      cardBrand: evt.value,
    })),
  },
}

また、今回の例だと配列の要素は1つですが、複数個オブジェクトを渡すことによって複数のイベントを送信することも可能です。

他の状態もイベントに対するアクションをonで定義しています。

最後にcompletedとerrorに関してはそれ以上状態が変化しない最後の状態なので、type: "final"としてそれ以上状態が遷移しないように定義しています。

{
  completed: {
    type: "final",
  },
  error: {
    type: "final",
  }
}

このようにXStateでは宣言的に状態遷移を定義できるため、非常に可読性がよく、開発者が意識することも少なくなっています。

ではステートマシンを定義できたので、これをアプリケーション全体で使用するためにContext APIに格納します。

公式ドキュメントの例を参照しています。

以下は_app.tsxの例です。

import { createContext, useContext, VFC } from "react";
import { AppProps } from "next/app";
import { useActor, useInterpret } from "@xstate/react";
import { ReservationFlowContext, reservationFlowMachine } from "../state";
import { AnyEventObject, Interpreter } from "xstate";

const StateContext = createContext<{
  reservationFlowService?: Interpreter<
    ReservationFlowContext,
    any,
    AnyEventObject,
    {
      value: any;
      context: ReservationFlowContext;
    }
  >;
}>({});

const MyApp: VFC<AppProps> = ({ Component, pageProps }) => {
  const reservationFlowService = useInterpret(reservationFlowMachine);

  return (
    <>
      <StateContext.Provider value={{ reservationFlowService }}>
        <Component {...pageProps} />
      </StateContext.Provider>
    </>
  );
};

export default MyApp;

useInterpretの引数に定義したステートマシンを渡して、戻り値をContextに格納しています。

このuseInterpretはステートマシンの静的な参照を返すので、無駄な再レンダリングは発生しません。

加えて、ステートマシンをアクターとして扱うためのcustom hooksを定義しておきます。

useActorはステートマシンをアクターとして扱うためのhooksで、現在の状態とそのアクターに対してメッセージを送信するためのsendメソッドをタプルで返します。

useStateライクなAPIとなっていてわかりやすいですね。

import { useActor } from "@xstate/react";
export const useReservationFlowState = () => {
  const stateContext = useContext(StateContext);
  const [state, send] = useActor(stateContext.reservationFlowService);
  return {
    state,
    send,
  };
};

では、実際にコンポーネントを作っていきます。

まずは予約日時を入力するcustom hooksを作成します。

このhooksはステートマシンに対してINPUT_DATEのイベントと入力値の日時を送信する関数を返します。

ReduxのActionと同様に発生し得るイベントは列挙して定義しています。

const ReservationFlowAction = {
  INPUT_DATE: "INPUT_DATE",
  INPUT_CUSTOMER_INFO: "INPUT_CUSTOMER_INFO",
  INPUT_PAYMENT: "INPUT_PAYMENT",
  CONFIRM: "CONFIRM",
  ERROR: "ERROR",
} as const;

type ReservationFlowAction =
  typeof ReservationFlowAction[keyof typeof ReservationFlowAction];

export const useInputReservationDate = () => {
  const { send } = useReservationFlowState();
  const handleInputDate = (value: { date: string }) => {
    send({
      type: ReservationFlowAction.INPUT_DATE,
      value: value.date,
    });
  };
  return handleInputDate;
};

そしてこのcustom hooksを使用してsubmit時にステートマシンに対してイベントを送信します。

※ 今回はプロトタイプの実装なので、かなり雑な実装となっていますが御了承ください。

import { VFC } from "react";

import { useInputReservationDate } from "../state";

export const DateSelect: VFC = () => {
  const handleInput = useInputReservationDate();
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        handleInput({ date: e.target["date"].value });
      }}
    >
      <div>Please input the reservation date</div>
      <input name="date" type="datetime-local" />
      <div>
        <button type="submit">submit</button>
      </div>
    </form>
  );
};

同様に他のイベントに対してもcustom hooksを作成し、それぞれのコンポーネントでステートマシンに対してイベントを送信します。

そしてpages/index.tsxに次のコンポーネントを定義します。

useReservationFlowStateで現在のステートマシンの状態を取得し、現在の状態に応じてレンダリングするコンポーネントを変えています。

import { NextPage } from "next";
import { DateSelect } from "../components/DateSelect";
import { useReservationFlowState } from "./_app";
import { CustomerInfo } from "../components/CustomerInfo";
import { PaymentInput } from "../components/PaymentInput";
import { Confirm } from "../components/Confirm";

const ReservationFlowState = {
  DATE: "date",
  CUSTOMER_INFO: "customerInfo",
  PAYMENT: "payment",
  CONFIRM: "confirm",
  COMPLETED: "completed",
  ERROR: "error",
} as const;

type ReservationFlowState =
  typeof ReservationFlowState[keyof typeof ReservationFlowState];

const IndexPage: NextPage = () => {
  const { state: currentState } = useReservationFlowState();

  return (
    <div>
      {currentState.matches(ReservationFlowState.DATE) && <DateSelect />}
      {currentState.matches(ReservationFlowState.CUSTOMER_INFO) && (
        <CustomerInfo />
      )}
      {currentState.matches(ReservationFlowState.PAYMENT) && <PaymentInput />}
      {currentState.matches(ReservationFlowState.CONFIRM) && <Confirm />}
      {currentState.matches(ReservationFlowState.COMPLETED) && (
        <div>completed!</div>
      )}
    </div>
  );
};
export default IndexPage;

以上の実装でこのような予約入力画面のプロトタイプを実装できました。 f:id:Tak-Iwamoto:20220413094609g:plain

その他のXStateのAPI

Statechartsの概念の中で先ほどのプロトタイプではカバーできていなかったAPIをいくつか説明します。

Guarded transitions

特定の状態を保持する場合のみ、ある状態遷移を可能にしたいというユースケースはしばしばあります。

例えば、ログイン状態のときのみ次のフローに進めるといったケースです。

Guarded transitionsを使用することで、このような特定の条件の場合のみに可能な状態遷移を定義できます。

contextとeventを引数にとる関数を定義し、statesのイベントオブジェクトのcondに定義したGuard関数を渡すことで、条件を満たすときのみ状態遷移することができます。

const isLoggedIn = (context, _event) => {
  return context.loginUser !== undefined
};

const loginUserSampleMachine = createMachine(
  {
    id: 'login-user-demo',
    initial: 'first',
    context: {
      loginUser: {name: "Tom"}
    },
    states: {
      first: {
        on: {
          NEXT: [
            {
              target: 'onlyLoginUserStep',
              cond: isLoggedIn
            },
          ]
        },
      },
      onlyLoginUserStep: {
        // ...
      },
    }
  },
  {
    guards: {
      isLoggedIn
    }
  }
);

Actions (entry, exit)

Actionsのtransitionについては説明しましたが、他にentryとexitのActionsがあります。

entryではその状態となったとき、exitでは次の状態に遷移するときにフックして処理が実行されます。

Contextを更新するactionsと同様にcontextとeventを引数に持つ関数を定義します。

以下はドキュメントの例をそのまま参照しています。

const triggerMachine = createMachine(
  {
    id: 'trigger',
    initial: 'inactive',
    states: {
      inactive: {
        on: {
          TRIGGER: {
            target: 'active',
            // transition actions
            actions: ['activate', 'sendTelemetry']
          }
        }
      },
      active: {
        // entry actions
        entry: ['notifyActive', 'sendTelemetry'],
        // exit actions
        exit: ['notifyInactive', 'sendTelemetry'],
        on: {
          STOP: { target: 'inactive' }
        }
      }
    }
  },
  {
    actions: {
      // action implementations
      activate: (context, event) => {
        console.log('activating...');
      },
      notifyActive: (context, event) => {
        console.log('active!');
      },
      notifyInactive: (context, event) => {
        console.log('inactive!');
      },
      sendTelemetry: (context, event) => {
        console.log('time:', Date.now());
      }
    }
  }
);

その他

他にも同時に複数の状態を取り得るケースを扱うためのParallel State, 階層構造の状態を扱うHierarchical State, 状態の履歴を保持するためのHistoryなど、Statechartsの理論に基づいたAPIが用意されています。

また、今回のデモでは1つのステートマシンのみで実装しましたが、複数のステートマシンを作成して相互に通信して状態遷移させることやイベントにフックして新たなステートマシンも生成できます。

Invokeでイベントを受け取った際に非同期処理を投げることもできます。

こういった点もステートマシンをアクターモデルとして扱うことができるXStateの優れた点です。

詳しくはActorsの章をご参照ください。

最後に

XStateについてかんたんなデモを通してコアとなる概念からReactでの実装方法を説明しました。

公式ドキュメントを眺めてみると色々勉強になることや気づきが得られるのでぜひ見てみてください。

また、 ヘイ株式会社では絶賛採用活動中です。カジュアル面談も積極的に行っているのでご気軽にご応募ください!