XState + Apollo Clientでフロントエンドのステート管理

こんにちは。メルカリのSoftware Engineerのsotaです。 この記事は、Mercari Advent Calendar 2021 の18日目の記事です。

先日限定公開が始まったmerworkのWebフロントエンドでは、より良いUXを実現するためにコードの段階から設計していこう、というテーマをもとに、ライブラリやフレームワークの選定を行なっています。その中でAPIのデータやアプリケーションのステート管理にはXStateとGraphQLクライアントであるApollo Clientを採用しています。今回はこれらのライブラリを採用した経緯やどのように運用しているかについて解説します。

XStateについて

XStateはステートチャートに基づいた状態遷移を管理するJavaScriptライブラリです。ReduxやMobXのようなフロントエンドのステート管理ライブラリと比較すると、XState独自のステート管理方法があるわけではありません。有限オートマトンやステートマシーンをXMLで記述できるようにしたW3G標準のSCXMLに準拠しており、この仕様を用いてステート管理を実現するのに便利な機能が揃っているライブラリです。ReactやVueなど各フロントエンドライブラリとのバインディングが提供されています。

ステートチャートについて

デザイナーが作成するUIのフローチャートをより詳細に作成したもの、とイメージしていただけると分かりやすいかなと思います。ステートチャートを使うと、画面遷移・エラー画面・ボタン文面など条件によった分岐を規格に合わせて作成できます。

XStateを採用した理由

merworkはユーザーがスキマ時間を使い小さなワークをこなしてポイントを獲得できるプラットフォームです。現在はメルカリの検索結果向上のため、類似語を判別するワークを提供中です。

ワークはゲームのようなUXを持っており、一つの画面に複数種別のユーザーインタラクションがあります。さらに、それぞれのインタラクションに対しワークの回答状況に応じて違うリアクションを返す必要があります。この複雑なUXフローを抜けなくコード上に正確に表せるという点からXStateを採用しました。

フローチャートをXStateで実装してみるサンプル

実際にmerworkのワークをはじめる際のUXフローを例にして、以下のフローチャートをXStateで実装してみます。

メルワークのフローチャート

これをXStateのコードで表すと以下のようになります。

const machine = Machine(
  {
    id: 'work',
    initial: 'idle',
    context: {
      answers: 0,
      answerLength: 10,
    },
    states: {
      idle: {
        invoke: { src: 'getWork', onDone: 'working', onError: 'error' },
      },
      working: {
        on: {
          ADD_ANSWER: [
            { target: 'completed', cond: 'isLastAnswer', actions: 'addAnswer' },
            { actions: 'addAnswer' },
          ],
          ABORT: [{ target: 'aborting' }],
        },
      },
      aborting: {
        on: {
          '': [{ cond: 'hasNoAnswer', target: 'aborted' }],
          CANCEL: { target: 'working' },
          ABORT: { target: 'aborted' },
        },
      },
      completed: {
        type: 'final',
      },
      error: {
        type: 'final',
      },
      aborted: {
        type: 'final',
      },
    },
  },
  {
    actions: {
      reset: assign({
        answers: (_) => 0,
      }),
      addAnswer: assign({
        answers: (ctx) => ctx.answers + 1,
      }),
    },
    guards: {
      isLastAnswer: (ctx) => ctx.answers + 1 === ctx.answerLength,
      hasNoAnswer: (ctx) => ctx.answers === 0,
    },
  }
)

また、上記のコードをビジュアライザーを使うと以下のようなステート図が生成されます。

メルワークのステートチャート

XState + Apollo Clientを組み合わせて使う

merwork APIはGraphQLを採用しており、クライアントにはApollo Clientを利用しています。何のルールもなく併用してしまうと、役割が被りコードが複雑になってしまうので、以下のような役割分担をまず最初に定めました。

XState

コンポーネント単位でのローカルステートを管理する。

認可状態の管理などコンポーネント間で共通のグローバルステートを管理する。

Apollo Client

リモートステートを管理する。APIから取得したデータやAPIへ送信されるデータを管理する。

XState + Apollo Clientのコード例

上記のサンプルコードをApollo ClientとReactで使用する際は、以下のように machineservices として Apollo Client の useQuery().refetch を渡します。

const useWorkMachine = () => {
  const { refetch, data } = useQuery(
    gql`
      query Work {
        work {
          id
        }
      }
    `,
    { skip: true } // コンポーネントがマウントされたときに自動でQueryが走るのを防ぐ
  )
  const service = useInterpret(machine, {
    services: {
      getWork: () => refetch(),
    },
  })

 return { data, service }
}

XState + Apollo Clientを採用してみて

よかったこと

  • ステートチャートに従ってビジネスロジックを表現することで、コードの書き方が統一できる
  • リモートステートはApollo Clientのキャッシュに一任することにより、ボイラープレートを書かずにミニマルなコード量をキープできる

よくなかったこと

  • XStateのTypeScriptサポートは限定的で、公式パッケージのみでは全てに型をつけられない
  • ステートチャートと、XStateライブラリの両方を学ぶ必要があり、学習コストが高い
  • Apollo Clientなど他のライブラリと組み合わせることで複数のライブラリの学習が必要になる

最後に

Webフロントエンドは特にその傾向が強いですが、特定のライブラリをステート管理で導入すると、そのライブラリのためのコードの書き方に縛られてしまいがちです。プロジェクト単位で別のステート管理ライブラリを学習しなければならない場面も多いです。それとは逆に、ステートチャートは歴史があり流行り廃れに関係なく、概念としてしっかりと仕様が書かれているので、コードの書き方の面でエンジニア間での意思疎通もやりやすいように思いました。

最後に、merworkでは現在チームメンバーを積極募集中です! カジュアルにお話したい方、採用情報の詳細を知りたい方は、こちらのページに情報を掲載していますのでぜひご覧ください。

明日の記事はnarusimさんです。引き続きお楽しみください。