4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

XStateで「前の状態に戻る」を実装したい

4
Last updated at Posted at 2025-12-18

TL; DR

  • XStateで任意の状態から、直前の状態に戻れるようにしたい
  • contextに訪れた状態の配列(histories)を持たせる
  • 戻る際はダミーの状態へ遷移し、always で即時 histories 末尾の状態へ遷移

はじめに

XStateを使用すると、状態遷移を伴うシステムを簡潔に記述することができます。

Reactに組み込むためのパッケージも用意されている1ため、コンポーネントの状態管理にも手軽に組み込めます。

そこで、質問フォームのページ遷移をXStateで管理することを考えました。
本記事では、XStateでフォームの戻るボタンを実装した際にハマったこととその対処法について紹介します。

バージョン

  • XState: 5.24.0
  • React: 19.1.16
  • TypeScript: 5.9.3

やりたいこと

以下のような質問フォームを考えています。

  • 1ページに1つずつ選択肢が出てくる
  • 選んだ選択肢によって、次に遷移する質問が変わる(質問を飛ばす)
  • どのページでも、戻るボタンで前の質問に戻れる

簡単な例として、ラーメン屋さんの食券機のシステムを2考えます。

作ってから気づいたのですがramenshopという名前はよろしくありませんね...「ラーメンショップ」とは無関係の、架空のラーメン屋ということでお願いします :bow:3

image.png

麺とトッピングについて、以下の順番で質問されます。
選択肢には分岐があり、麵量「ミニ」の場合ニンニクの量は「普通」固定になります (そんなお店ある?)

image.png

「Next」を押すと次の質問(=次の状態)へ進み、「Back」で前の質問に戻ります。

ただし、直前の状態に戻る機能はXStateでは用意されていませんでした4
自前で、任意の状態から直前の状態に戻る機構を実装する必要があります。

上手くいかなかった案

まずは素朴に、各状態に前の状態の情報を持たせることを考えました。
ここで問題になるのが分岐です。

上記の例だと、「ニンニク」の質問へ遷移するのは「麵量」「ヤサイ」の2パターンあります。
戻る場合は、実際に直前に回答した質問を指定する必要があるため、決め打ちで遷移先を定義できません。

    garlic: {
      on: {
        back: {
          target: "???", // "vegitable" or "size"
        },
      },
    },

もちろんここで条件分岐を実装することはできますが、「麵量」で定義した分岐をもう一度コピペする必要があり煩雑です。
(状態が増えたときに修正漏れも起きそうです...)

解決策:状態の履歴をcontextに持たせる

そこで、これまでに経由した状態の履歴一覧をcontextに保存することで、前の状態に戻れるようにしました。

全体のソースコードはこちらにあります。

contextに訪れた状態一覧を持たせる

まずは、過去に訪れた状態を覚えておくためにcontextに保存します。

histories という配列を用意し、次の状態へ遷移する直前のタイミングで自分の状態名を末尾に追加しています。

state/machine.ts
  states: {
    size: { // 麺量
      on: {
        next: [
          {
            target: "vegetable", // 麺量「ミニ」の場合は「ヤサイ」へ遷移
            guard: ({context} : {context: RamenContext}) => context.size === "mini",
            // 遷移前に自身の状態を履歴に追加
            actions: assign({
              histories: ({context} : {context: RamenContext}) => [...context.histories, "size"],
            }),
          },
          {
            target: "garlic", // それ以外の場合は「ニンニク」へ遷移
            // 遷移前に自身の状態を履歴に追加
            actions: assign({
              histories: ({context} : {context: RamenContext}) => [...context.histories, "size"],
            }),
          },
        ],
      },
    },
// ...

(※冗長なので assign の処理はまとめたかったのですが、外部から与えると型が合わなかったためべた書きしています)

前の状態に戻る

続いて、histories を見て前の状態に戻る部分を実装します。
とはいえ、前述の通り遷移先は動的に決まるため事前に定義できません。

そこで、一律ダミーの状態 history へ遷移させます。

state/machine.ts
  states: {
	// ...
    garlic: {
      on: {
		// ...
        back: {
          target: "history",
        },
      },
    },
    vegetable: {
      on: {
		// ...
        back: {
          target: "history",
        },
      },
    },
	// ...

状態 history では always を使用し、無条件でただちに次の状態へ遷移します。

遷移先としては事前に全状態分定義しておき、 histories の末尾要素にもとづいて選ばれます。

state/machine.ts
    history: {
	  // ただちに次の状態へ遷移
	  // 動的に全状態への遷移の定義を作成
      always: stateKeys.map(key => ({
        target: key,
        guard: ({context}: {context: RamenContext}) => context.histories[context.histories.length -1] === key
      })),
      exit: assign({
        // 状態遷移直前に履歴historiesの末尾を削除
        histories: ({context} : {context: RamenContext}) => context.histories.slice(0, -1),
      }),
    },

これで、前の状態へ戻ることができます。

おわりに

以上、XStateで前の状態へ戻る方法の紹介でした。実装が少し複雑になってしまうため良し悪しではあるのですが、ある程度XStateのしくみに則ったまま実現することができました。

  1. XState自体は状態管理ライブラリなので、JSX等と関係ない場所でも使用できます。

  2. 「G系では、麺量だけ食券で選んでコールでトッピングを選ぶ方が多いのでは?」という疑問はごもっともです。今回は全部食券で選べるお店ということにしてください。

  3. そもそもG系ではありませんし...いっそネギの量を選べるようにすれば良かった?(開き直り)

  4. historyという機能はありますが、これは子状態の間を移動しても前に訪れた子状態を覚えておくための機能です。遷移前の状態に戻るためのものではありません。

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?