XState入門(+おまけでReact Router v7)
XState + React Router v7で状態遷移を実装してみよう
こんにちは!ソニックムーブエンジニアのchiakiです!
今回はXStateを利用しての状態遷移実装を初めて行ったので、入門編として記事にまとめていきたいと思います!
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
: 状態遷移時やイベント発生時にcontext
やevent
が意図しているかの検証をすることができます。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を使ったサインアップ画面例
今回は以下のディレクトリ構成で作成しました。
リポジトリはこちら
.
└── 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