XState入門(+おまけでReact Router v7)

2025/02/28に公開

XState + React Router v7で状態遷移を実装してみよう

こんにちは!ソニックムーブエンジニアのchiakiです!
今回はXStateを利用しての状態遷移実装を初めて行ったので、入門編として記事にまとめていきたいと思います!

https://xstate.js.org/

XStateとは

※公式より引用

XState は、JavaScript および TypeScript アプリの状態管理およびオーケストレーション ソリューションです。
開発者がロジックをアクターとステートマシンとしてモデル化できるようにすることで、アプリケーションとワークフローの状態を管理する強力で柔軟な方法を提供します。

プログラミング言語によって実装されたアプリケーションでは、常に様々な入力を受け付けそれによって様々なイベントが起きます。
その際に起こり得るアプリケーションの状態遷移や副作用、そしてロジックをステートマシンという単位でモデル化します。

ステートマシンを利用することにより、モジュールやUIコンポーネントの様々な箇所に存在する状態やイベントを一元管理できるようになり、複雑さを軽減させ保守性を向上させることが可能となります。

TypeScriptプロジェクトでのステートマシン作成例

最も簡単な手段として、setup()createMachine()を使うことによりステートマシンを作成することができます!

import { setup, createMachine } from "xstate";

export const hogeMachine = setup({ /* some properties... */ }).createMachine();

// 或いは

export const hogeMachine = createMachine({ /* some properties... */ });

構成要素

XStateのステートマシンは主に以下の要素で構成されています。

  • states: アプリケーションの状態一覧。親子状態を定義することも可能
  • events: イベント一覧。アプリケーションで起こる様々なイベントの定義。簡単なものでいえばCLICKなど
  • context: 状態間で利用される値の定義。カウンタの値やフォームの入力値などが該当する。扱いとしてはイミュータブルなのでassign関数を使用しないと値を更新できない
  • actions: イベント発生時の副作用の処理を定義できる。カウンタをインクリメントしたりなど
  • guards: 状態遷移時やイベント発生時にcontexteventが意図しているかの検証をすることができます。HTMLフォームのsubmit時のバリデーションなどが該当するかと思います。

また、setup()を使うことによりステートマシンの初期化を行うことができます。

import { setup, assign } from 'xstate';

const machine = setup({
  types: {
    context: {} as { count: number }, // contextの定義
    events: {} as { type: 'inc' } | { type: 'dec' }, // eventsの定義
  },
  actions: {
    // contextはイミュータブルなのでassignを使って値を更新する
    increment: assign({
      count: ({ context }) => context.count + 1,
    }),
    decrement: assign({
      count: ({ context }) => context.count - 1,
    }),
  },
}).createMachine({
  context: { count: 0 },
  on: {
    inc: { actions: 'increment' },
    dec: { actions: 'decrement' },
  },
});

状態遷移と副作用について

各状態間の遷移は、状態名にトリガを設定することで可能となります。
以下はstates.状態名.on.targetを使用して設定している例となります。

import { setup } from "xstate";

export type ToggleEvent = { type: "toggle" };

export const toggleMachine = setup({
  types: {} as { events: ToggleEvent },
}).createMachine({
  id: "toggle",
  initial: "inactive", // 初期状態
  states: {
    inactive: {
      on: { toggle: { target: "active" } }, // toggleイベント発生時に状態をactiveに遷移
    },
    active: {
      on: { toggle: { target: "inactive" } }, // toggleイベント発生時に状態をinactiveに遷移
    },
  },
});
"use client";

import { toggleMachine } from "@/machines/toggle-machine";
import { useMachine } from "@xstate/react";

export function ToggleButton() {
  const [state, send] = useMachine(toggleMachine);
  const isActive = state.matches("active");

  return (
    <>
      <button onClick={() => send({ type: "toggle" })} /> {/* onClickでtoggleイベントをステートマシンに送信 */}
      <span>{state.value}</span>
    </>
  );
}

副作用ですが、対応するトリガにactionsを渡すことで実現できます。下記からは、主なトリガを解説していきます。

on

onはイベント発生時の遷移先や副作用の紐づけを行うことができます。最もシンプルで使う場面が多いかと思います。

setup({
  actions: {
    lampOn: () => // do something...
  }
}).
createMachine({
  id: "powerSwitch",
  initial: "stopped",
  context: {
    elapsed: 0,
  },
  states: {
    stopped: {
      on: {
        start: { 
          target: "running", // 遷移先の状態名
          actions: "lampOn", // 処理される副作用名
        },
      },
    },
  }
})

entry

entryはその状態へ遷移した直後の副作用アクションを定義できます。

states: {
  goaled: {
    entry: "fanfare",
  },
},

exit

exitはentryの逆で、状態から他の状態へ遷移する直前の副作用アクションを定義できます。

states: {
  checkOut: {
    exit: "doorLock",
  },
},

always

alwaysはその状態へ遷移した際に毎回実行される状態遷移やguardsを利用した検証を設定できます。一見entryと似ていますが、entryは状態遷移自体は発生させないことに注意が必要です。

states: {
  // チェック状態に入った直後に、自動でloggedInかloggedOutに遷移
  checking: {
    always: [
      {
        target: "loggedIn",
        cond: /* guardsは関数自体を設定するがcondは条件の指定 */ (context) => context.loggedIn
      },
      { target: "loggedOut" }
    ]
  },

after

状態に遷移してからmsec後の遷移と副作用アクションを設定できます。

states: {
  idle: {
    // idle状態に入った後、5000ミリ秒経過すると自動でtimeoutに遷移
    after: {
      5000: { target: 'timeout', actions: 'notifyTimeout' }
    },
    on: {
      START: 'active'
    }
  },
  timeout: {
    // タイムアウト後の状態
  }

React Router v7を使ったサインアップ画面例

今回は以下のディレクトリ構成で作成しました。
リポジトリはこちら

https://github.com/doubutsunokarada/learn-xstate-react-router

.
└── app/
    └── auth/
        ├── actions -> ルートページからexportされるactionの定義
        ├── components -> UIコンポーネントをまとめている
        ├── machines -> ステートマシンの定義
        ├── pages -> ルートページのtsxファイルが置いてある
        └── schema -> フォームバリデーション用のスキーマ及び型定義が置いてある

ステートマシンの定義は以下です。
unauthorizeという親状態の下に、HTMLフォーム入力中の状態signupFormと、入力確認の状態confirmを設定しています。

import { setup } from "xstate";

type AuthContext = {
  error: string | null;
};

type AuthEvents =
  | { type: "SIGNUP" }
  | { type: "CONFIRM" }
  | { type: "BACK" }
  | { type: "ERROR"; error: string };

export const authMachine = setup({
  types: {} as { event: AuthEvents; context: AuthContext },
  actions: {
    setError: ({ context }) => (context.error = "Invalid credentials"),
    clearError: ({ context }) => (context.error = null),
  },
}).createMachine({
  id: "auth",
  initial: "unauthorized",
  context: { user: null, error: null },
  states: {
    unauthorized: {
      initial: "signupForm",
      states: {
        signupForm: {
          on: {
            CONFIRM: { target: "confirm" },
          },
        },
        confirm: {
          on: {
            SIGNUP: { target: "#auth.created" },
            BACK: { target: "signupForm" },
            ERROR: { actions: "setError" },
          },
        },
      },
    },
    created: {
      entry: "clearError",
    },
  },
});

コンポーネントは以下となります。Conform.useFormに登録したスキーマでのバリデーションに成功したら入力確認画面状態(confirm)へ遷移し、それを利用して各inputの表示制御を行っています。

"use client";

import { type FC } from "react";
import { signup } from "../actions";
import { useActionData, useSubmit, useFetcher } from "react-router";
import { useForm } from "@conform-to/react";
import { type Submission, schema } from "../schema";
import { parseWithZod } from "@conform-to/zod";
import { useMachine } from "@xstate/react";
import { authMachine } from "../machines";
import { twMerge } from "tailwind-merge";

export const SignupForm: FC = () => {
  const lastResult = useActionData<typeof signup>();
  const submit = useSubmit();
  const fetcher = useFetcher();
  const [form, fields] = useForm<Submission>({
    lastResult,
    onValidate: ({ formData }) => parseWithZod(formData, { schema }),
    onSubmit: (e, ctx) => {
      e.preventDefault();
      console.log(ctx.formData);
      submit(e.currentTarget, { method: "POST" });
    },
  });

  const [state, send] = useMachine(authMachine);
  const sendConfirm = () => {
    form.validate();
    if (!form.valid) return;
    send({ type: "CONFIRM" });
  };
  const onBack = () => send({ type: "BACK" });
  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    form.onSubmit(e);
  };

  const isSignupForm = state.matches({ unauthorized: "signupForm" });
  const isConfirm = state.matches({ unauthorized: "confirm" });

  const signupFormClasses = twMerge(
    "w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-md text-slate-200 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-200"
  );
  const confirmFormClasses = twMerge(
    "w-full px-4 py-2 bg-slate-800 rounded-md text-slate-200"
  );

  return (
    <div className="w-full max-w-md p-8 bg-slate-800 rounded-lg shadow-lg">
      <h2 className="text-2xl font-bold text-slate-200 mb-6">
        {isSignupForm ? "アカウント作成" : "入力内容の確認"}
      </h2>
      <fetcher.Form
        id={form.id}
        className="space-y-6"
        noValidate
        onSubmit={onSubmit}
      >
        <div>
          <label
            htmlFor={fields.name.id}
            className="block text-sm font-medium text-slate-200 mb-2"
          >
            名前
          </label>
          <input
            type="text"
            id={fields.name.id}
            name={fields.name.name}
            readOnly={isConfirm}
            className={
              isSignupForm
                ? signupFormClasses
                : isConfirm
                ? confirmFormClasses
                : signupFormClasses
            }
            placeholder="山田 太郎"
          />
        </div>

        <div>
          <label
            htmlFor={fields.email.id}
            className="block text-sm font-medium text-slate-200 mb-2"
          >
            メールアドレス
          </label>
          <input
            type="email"
            id={fields.email.id}
            name={fields.email.name}
            readOnly={isConfirm}
            className={
              isSignupForm
                ? signupFormClasses
                : isConfirm
                ? confirmFormClasses
                : signupFormClasses
            }
            placeholder="your@email.com"
          />
        </div>

        <div>
          <label
            htmlFor={fields.password.id}
            className="block text-sm font-medium text-slate-200 mb-2"
          >
            パスワード
          </label>
          <input
            type="password"
            id={fields.password.id}
            name={fields.password.name}
            readOnly={isConfirm}
            className={
              isSignupForm
                ? signupFormClasses
                : isConfirm
                ? confirmFormClasses
                : signupFormClasses
            }
            placeholder="8文字以上32文字以下"
          />
        </div>

        <div>
          <label
            htmlFor={fields.passwordConfirm.id}
            className="block text-sm font-medium text-slate-200 mb-2"
          >
            パスワード(確認)
          </label>
          <input
            type="password"
            id={fields.passwordConfirm.id}
            name={fields.passwordConfirm.name}
            readOnly={isConfirm}
            className={
              isSignupForm
                ? signupFormClasses
                : isConfirm
                ? confirmFormClasses
                : signupFormClasses
            }
            placeholder="パスワードを再入力"
          />
        </div>

        <div className={`flex items-center ${isConfirm && "hidden"}`}>
          <input
            type="checkbox"
            id={fields.terms.id}
            name={fields.terms.name}
            readOnly={isConfirm}
            className="h-4 w-4 rounded border-slate-600 text-blue-600 focus:ring-blue-500 bg-slate-700"
          />
          <label
            htmlFor={fields.terms.id}
            className="ml-2 block text-sm text-slate-200"
          >
            <span>利用規約と</span>
            <a href="#" className="text-blue-400 hover:text-blue-300 mx-1">
              プライバシーポリシー
            </a>
            <span>に同意する</span>
          </label>
        </div>

        {isSignupForm && (
          <button
            onClick={sendConfirm}
            type="button"
            disabled={fetcher.state === "submitting"}
            className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-slate-800 transition duration-200"
          >
            アカウント作成
          </button>
        )}
        {isConfirm && (
          <div className="flex w-full gap-3">
            <button
              type="button"
              onClick={onBack}
              className="flex-1 px-4 py-2 bg-slate-600 hover:bg-slate-700 text-white font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 focus:ring-offset-slate-800 transition duration-200"
            >
              戻る
            </button>
            <button
              type="submit"
              className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-slate-800 transition duration-200"
            >
              送信する
            </button>
          </div>
        )}

        <div className="text-center text-sm text-slate-400">
          すでにアカウントをお持ちの方は
          <button className="text-blue-400 hover:text-blue-300 ml-1">
            ログイン
          </button>
        </div>
      </fetcher.Form>
    </div>
  );
};

使ってみての感想

自分はフロントエンドエンジニアなので、実際に利用していくのは@xstate/reactもしくは@xstate/vueになるかと思います。
ただ内部的にuseStateを利用しているのでサーバコンポーネントで使用できず、これからの流れにうまく適応できるかどうか・・😭

UIコンポーネント部分のみの表示制御などに使う分にはかなり良さそうな印象を受けたので、実務でも使えそうなチャンスを見つけていきたいと思います!

最後までお読み頂き、ありがとうございました!

株式会社ソニックムーブ

Discussion