XState の簡易チュートリアル
概要
XState とは、JavaScript でステートマシンを実装するライブラリーです。ステートマシンを使ってアプリケーションの状態をモデル化し、状態遷移を管理できます。
ステートマシンを使うメリットはこちらです。
- ありえない状態を排除できる
- 状態、入力、遷移を明確化し、システムの振る舞いを理解できる
この記事では公式の「Get Started」の「Quick Start」を参照しながら XState を使い、有限ステートマシンを作成し、Toggle ボタンを作成する方法を紹介します。
ステートマシンとは何か
「ステートマシン」と省略していますが、正確に記述すると「有限ステートマシン」です。
有限ステートマシンとは有限な数の状態(ステート)と状態遷移(トランジション)を表現したモデルです。ある時点では1つの状態しかとらず、何かしらの入力(インプット)があると、別の状態に遷移します。
例1)ボールペンの例
例えばボールペンをイメージしてみましょう。ボールペンのペンはボタンを押すことでインクが出るようになっています。ボールペンの状態をステートマシンで表現すると以下のようになります。
-
状態
- ペン先が出ている
- ペン先が出ていない
-
入力
- ボタンをクリック
-
遷移
- ペン先が出ている状態でボタンをクリックすると、ペンが出ていない状態に遷移する
- ペン先が出ていない状態でボタンをクリックすると、ペンが出ている状態に遷移する
状態遷移図
「状態遷移図」で表現するとこのようになります。
状態遷移表
「状態遷移表」を用いることで、状態遷移の規則を表に表す事ができます。
現在の状態 | 入力 | 次の状態 |
---|---|---|
ペン先が出ている | ボタンをクリック | ペン先が出ていない |
ペン先が出ていない | ボタンをクリック | ペン先が出ている |
例2)正規表現の例
正規表現 abc*
を記述する有限ステートマシンを作成します。abc*
は a
の後に b
が続き、その後に c
が 0 回以上続く文字列を表します。
文字列 | 受け入れ可否 |
---|---|
a | ✗ |
ab | ◯ |
abc | ◯ |
abcc | ◯ |
b | ✗ |
abb | ✗ |
c | ✗ |
状態遷移図
正規表現 abc*
の条件を満たす状態、つまり「受容状態」は二重丸で表現されます。今回はそれぞれの状態に ID を振っています。見て分かる通り、状態 0
から状態 1
に遷移するときには a
が入力され、状態 1
から状態 2
に遷移するときには b
が入力されます。abc*
の条件を満たす状態を「受容状態」と呼びます。
状態遷移表
現在の状態 | 入力 | 次の状態 |
---|---|---|
0 | a | 1 |
0 | b | 3 |
0 | c | 3 |
1 | a | 3 |
1 | b | 2 |
2 | a | 3 |
2 | b | 3 |
2 | c | 2 |
3 | a | 3 |
3 | b | 3 |
3 | c | 3 |
例3)Toggle ボタンの例
Toggle ボタンをステートマシンで表現すると以下のようになります。
状態遷移図
ボールペンと異なり初期状態を追加しています。初期状態とは有限ステートマシンが開始されたとき、最初に入る状態です。今回、初期状態はオフだとしています。
状態遷移表
「状態遷移表」を用いることで、状態遷移の規則を表に表す事ができます。
現在の状態 | 入力 | 次の状態 |
---|---|---|
オフ | ボタンをクリック | オン |
オン | ボタンをクリック | オフ |
この記事では、XState を使って有限ステートマシンを作成し、Toggle ボタンをを作成する方法を紹介します。
Next.jsで作業環境を構築
作業するための Next.js のプロジェクトを新規に作成していきます。長いので、折り畳んでおきます。
新規プロジェクト作成と初期環境構築の手順詳細
プロジェクトを作成
create next-app@latest
でプロジェクトを作成します。
$ pnpm create next-app@latest next-xstate-tutorial --typescript --eslint --import-alias "@/*" --src-dir --use-pnpm --tailwind --app
$ cd next-xstate-tutorial
Peer Dependenciesの警告を解消
Peer dependenciesの警告が出ている場合は、pnpm install
を実行し、警告を解消します。
WARN Issues with peer dependencies found
.
├─┬ eslint-config-next 14.2.1
│ └─┬ @typescript-eslint/parser 7.2.0
│ └── ✕ unmet peer eslint@^8.56.0: found 8.0.0
└─┬ next 14.2.1
├── ✕ unmet peer react@^18.2.0: found 18.0.0
└── ✕ unmet peer react-dom@^18.2.0: found 18.0.0
以下を実行することで警告が解消されます。
$ pnpm i -D eslint@^8.56.0
$ pnpm i react@^18.2.0 react-dom@^18.2.0
不要な設定を削除し、プロジェクトを初期化します。
styles
CSSなどを管理するstylesディレクトリを作成します。globals.css
を移動します。
$ mkdir -p src/styles
$ mv src/app/globals.css src/styles/globals.css
globals.css
の内容を以下のように上書きします。
@tailwind base;
@tailwind components;
@tailwind utilities;
初期ページ
app/page.tsx
を上書きします。
import { type FC } from "react";
const Home: FC = () => {
return (
<div className="">
<div className="text-lg font-bold">Home</div>
<div>
<span className="text-blue-500">Hello</span>
<span className="text-red-500">World</span>
</div>
</div>
);
};
export default Home;
レイアウト
app/layout.tsx
を上書きします。
import "@/styles/globals.css";
import { type FC } from "react";
type RootLayoutProps = {
children: React.ReactNode;
};
export const metadata = {
title: "Sample",
description: "Generated by create next app",
};
const RootLayout: FC<RootLayoutProps> = (props) => {
return (
<html lang="ja">
<body className="">{props.children}</body>
</html>
);
};
export default RootLayout;
TailwindCSSの設定
TailwindCSSの設定を上書きします。
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
plugins: [],
}
export default config
TypeScriptの設定
TypeScriptの設定を上書きします。
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": ".",
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
スクリプトを追加
型チェックのスクリプトを追加します。
{
"name": "next-tsconfig-resolvejsonmodule",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
+ "typecheck": "tsc"
},
"dependencies": {
"next": "14.1.4"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "14.1.4",
"postcss": "^8.4.38",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwindcss": "^3.3.0",
"typescript": "^5"
}
}
動作確認
ローカルで動作確認します。
$ pnpm run dev
コミットして作業結果を保存しておきます。
$ git add .
$ git commit -m "feat:新規にプロジェクトを作成し, 作業環境を構築"
XStateをインストール
XState をインストールします。
$ pnpm install xstate @xstate/react
コミットします。
$ git add .
$ git commit -m "feat:add xstate"
簡易なToggleボタンを作成
XState ではステートマシンはオブジェクトであり、オブジェクトは全てのロジックを持っています。XState の基本的な使い方を理解するために、簡易な Toggle マシンを作成します。Toggle マシンは Active
と Inactive
の 2 つの状態を持ち、toggle
イベントで状態を切り替えます。
$ mkdir -p src/machines
$ touch src/machines/toggle-machine.ts
import { createMachine } from 'xstate';
export const toggleMachine = createMachine({
id: 'toggle',
initial: 'Inactive',
states: {
Inactive: {
// targetを省略しない記述方法
on: { click : { target: 'Active' } }
},
Active: {
// targetを省略した記述方法
on: { click: 'Inactive' },
},
},
});
createMachine
でステートマシンを作成します。
export const toggleMachine = createMachine({
...
});
id
はステートマシンの名前です。
{
id: 'toggle',
}
states
は状態を表します。ここでは、ボタンを有効化されていない Inactive
と有効化されている Active
の 2 つの状態を持つステートマシンを作成しています。
{
states: {
Inactive: {
...
},
Active: {
...
},
},
});
on
は状態遷移を表します。click
イベントが発生すると、Inactive
から Active
に、Active
から Inactive
に遷移します。
{
Inactive: {
on: { type: "click", target: "Active" },
},
Active: {
on: { type: "click", target: "Inactive" },
},
},
}
initial
は初期状態を表します。Inactive
が設定されているので初期状態は state
で指定されている Inactive
になります。
{
initial: 'Inactive',
}
コミットします。
$ git add .
$ git commit -m "feat:add toggle state machine"
VSCodeの拡張機能で状態を可視化
VSCode の拡張機能を追加し、VSCode 上で XState の状態を可視化します。
こちらからインストールします。
VSCode の拡張機能をおすすめとして設定します。
$ mkdir -p .vscode
$ touch .vscode/extensions.json
{
"recommendations": ["statelyai.stately-vscode"]
}
toggle-machine.ts
を開くと、「Open Visual Editor」が追加されています。
クリックすると、ステートマシンを XState Editor で確認できます。
- ①ステートマシンの状態が表示されています。
- ②ソースコードの方にコメントが追加されています。これは、XState Editor で編集した内容をソースコードに反映するための情報が記載されています。公式のコメントはこちら。
- ③XState Editor のメニューで Edit を選択すると、ステートマシンを編集できます。
- ④XState Editor のメニューで Simulate を選択すると、ステートマシンをシミュレーションできます。
シミュレーションの参考例はこちらです。
コミットします。
$ git add .
$ git commit -m "feat:add xstate visual editor"
ステートマシンをReactで利用
ステートマシンを Client Component で利用します。
$ mkdir -p src/components
$ touch src/components/toggle-state.tsx
"use client";
import { useMachine } from "@xstate/react";
import { toggleMachine } from "@/machines/toggle-machine";
export const ToggleState = () => {
const [state, send] = useMachine(toggleMachine);
return (
<>
<div>{JSON.stringify(state.value)}</div>
<button
onClick={() => send({ type: "click" })}
className="bg-blue-200 rounded-md py-1 px-3 text-slate-800 text-base"
>
Toggle
</button>
</>
);
};
state
はステートマシンの状態を表します。send
はステートマシンにイベントを送信します。
const [state, send] = useMachine(toggleMachine);
state.value
でステートマシンの状態を取得し、画面に表示します。
<div>{JSON.stringify(state.value)}</div>
ボタンをクリックすると、send({ type: "click" })
でステートマシンに click
イベントが送信され、状態が Active
と Inactive
で切り替わります。
<button
onClick={() => send({ type: "toggle" })}
className="bg-blue-200 rounded-md py-1 px-3 text-slate-800 text-base"
>
Toggle
</button>
page.tsx
を編集します。
+import { ToggleState } from "@/components/toggle-state";
import { type FC } from "react";
const Home: FC = () => {
return (
+ <>
+ <ToggleState />
+ </>
- <div className="">
- <div className="text-lg font-bold">Home</div>
- <div>
- <span className="text-blue-500">Hello</span>
- <span className="text-red-500">World</span>
- </div>
- </div>
);
};
export default Home;
ローカルで動作確認します。ボタンをクリックすると、状態が切り替わります。
$ pnpm run dev
コミットします。
$ git add .
$ git commit -m "feat:add toggle state component"
Delayed Transitions
Delay transitionsを利用することで、ステートマシンの遷移を指定の時間の経過後に自動的に行うことができます。以下の例では、Inactive
から Active
に遷移すると、2 秒後に Inactive
へ自動的に遷移します。
import { createMachine } from 'xstate';
export const toggleMachine = createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QBcD2UoBswDoCSAdgIYDGyAlgG5gDEJm5JA1gNoAMAuoqAA6qzkKqAtxAAPRAEYAzACYcATgDsMgKwAOWWwAs03bOkAaEAE9EWtji2btGybck6Avk+NoM2HAEEyVWvUZWTlE+ASERJHEpOUUVaQ0tXX0jU0RtADYcePVpe2189XV09NkXVxACVAg4UXcsMBD+QXJhUQkEAFpinCUFWQKFdW0FNklB4zNO7UsFdNsFEdVpEqUlFzd0evxiX2pGsJaI0HaOyXUevoGhkbH1CaktRSLZnPSFSXS1dZA6zx8KPaRULNVqRdqyJSWWTqJQ5dSzaRsYopSaSR6DN7pV7vT7SNZlIA */
id: 'toggle',
initial: 'Inactive',
states: {
Inactive: {
// targetを省略しない記述方法
on: { click : { target: 'Active' } }
},
Active: {
// targetを省略した記述方法
on: { click: 'Inactive' },
+ after: { 2000: 'Inactive' },
},
},
});
ローカルで動作確認します。ボタンをクリックすると、状態が切り替わります。Inactive
から Active
に遷移すると、2 秒後に Inactive
へ自動的に遷移します。
$ pnpm run dev
コミットします。
$ git add .
$ git commit -m "feat:add delayed transitions"
Context
Context
でステートマシンの状態を保持できます。
context
に count
の変数を追加します。さらに、Active
へ遷移すると count
はインクリメントさせるために、entry
を追加します。entry
では該当の状態へ入るときに実行されるアクションを指定できます。
-import { createMachine } from 'xstate';
+import { assign, createMachine } from 'xstate';
export const toggleMachine = createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QBcD2UoBswDoCSAdgIYDGyAlgG5gDEaG2A2gAwC6ioADqrORagQ4gAHogCMAVgAcOAMwBOWQCZlU5syUB2WQBYdAGhABPcbLE4xsiTrFKp8zRPVSAbAF83h+llwBBMlS03kxsQty8-IJIIuLScooqdupaugbGiFLmykryljryUhJiOmYeniAEqBBwQsFgYTx85AJCoggAtC6GJh0uOOoDg0OaHl7oPvjEAdQNEc1RoG06St0Zmv0uEvIuipm6VkqjIHU4-hQz0eFNLdFtSi7rQzpSOpovOtarCPY4EpvbuzyVjKbiAA */
id: 'toggle',
+ context: { count: 0 },
initial: 'Inactive',
states: {
Inactive: {
on: { toggle: "Active" },
},
Active: {
+ entry: assign({
+ count: ({ context }) => context.count + 1
+ }),
on: { toggle: 'Inactive' },
after: { 2000: 'Inactive' },
},
},
});
画面上に count
を表示します。
"use client";
import { useMachine } from "@xstate/react";
import { toggleMachine } from "@/machines/toggleMachine";
export const ToggleState = () => {
const [state, send] = useMachine(toggleMachine);
return (
<>
<div>{JSON.stringify(state.value)}</div>
+ <div>{state.context.count}</div>
<button
onClick={() => send({ type: "toggle" })}
className="bg-blue-200 rounded-md py-1 px-3 text-slate-800 text-base hover:bg-blue-400"
>
Toggle
</button>
</>
);
};
ローカルで動作確認します。Active
に状態が推移すると、count
がインクリメントされ、Delayed Transitions も適応されているため 2 秒後に Inactive
へ遷移します。
$ pnpm run dev
コミットします。
$ git add .
$ git commit -m "feat:add context"
guard
guard
は遷移を許可または拒否するための関数です。
context
に maxCount
を追加し count
の最大値を 3 にします。guard
で count
の値が 3 以上の場合は状態遷移を拒否します。
import { assign, createMachine } from "xstate";
export const toggleMachine = createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QBcD2UoBswDoCSAdgIYDGyAlgG5gDEJm5JA1gNoAMAuoqAA6qzkKqAtxAAPRAEYAzACYcATgDsMgKwAOWWwAs03bOkAaEAE9EWtji2btGybck6Avk+NoM2HAEEyVWvUZWTlE+ASERJHEpOUUVaQ0tXX0jU0RtADYcePVpe2189XV09NkXVxACVAg4UXcsMBD+QXJhUQkEAFpinCUFWQKFdW0FNklB4zNO7UsFdNsFEdVpEqUlFzd0evxiX2pGsJaI0HaOyXUevoGhkbH1CaktRSLZnPSFSXS1dZA6zx8KPaRULNVqRdqyJSWWTqJQ5dSzaRsYopSaSR6DN7pV7vT7SNZlIA */
id: "toggle",
initial: "Inactive",
context: {
count: 0,
+ maxCount: 3,
},
states: {
Inactive: {
// targetを省略しない記述方法
on: {
click: {
target: "Active",
+ guard: ({ context }) => context.count < context.maxCount,
},
},
},
Active: {
entry: assign({
count: ({ context }) => context.count + 1,
}),
// targetを省略した記述方法
on: { click: "Inactive" },
after: { 2000: "Inactive" },
},
},
});
ローカルで動作確認します。count
が 3 になると Inactive
から Active
に状態が遷移できなくなります。
$ pnpm run dev
コミットします。
$ git add .
$ git commit -m "feat:add guard"
types
現状型安全ではありまんせん。
存在しないイベント名に変更します。
"use client";
import { useMachine } from "@xstate/react";
import { toggleMachine } from "@/machines/toggle-machine";
export const ToggleState = () => {
const [state, send] = useMachine(toggleMachine);
return (
<>
<div>{JSON.stringify(state.value)}</div>
<div>{state.context.count}</div>
<button
- onClick={() => send({ type: "click" })}
+ onClick={() => send({ type: "clickaa" })}
className="bg-blue-200 rounded-md py-1 px-3 text-slate-800 text-base"
>
Toggle
</button>
</>
);
};
tsc
で型チェックをします。期待する動作としてはエラーが出ることです。ですが、エラーは出ません。
$ pnpm run typecheck
イベントの型を追加します。
import { assign, createMachine } from "xstate";
export const toggleMachine = createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QBcD2UoBswDoCSAdgIYDGyAlgG5gDEJm5JA1gNoAMAuoqAA6qzkKqAtxAAPRAEYAzACYcATgDsMgKwAOWWwAs03bOkAaEAE9EWtji2btGybck6Avk+NoM2HAEEyVWvUZWTlE+ASERJHEpOUUVaQ0tXX0jU0RtADYcePVpe2189XV09NkXVxACVAg4UXcsMBD+QXJhUQkEAFpinCUFWQKFdW0FNklB4zNO7UsFdNsFEdVpEqUlFzd0evxiX2pGsJaI0HaOyXUevoGhkbH1CaktRSLZnPSFSXS1dZA6zx8KPaRULNVqRdqyJSWWTqJQ5dSzaRsYopSaSR6DN7pV7vT7SNZlIA */
id: "toggle",
initial: "Inactive",
+ types: {} as {
+ events: {
+ type: "click";
+ }
+ },
context: {
count: 0,
maxCount: 3,
},
states: {
Inactive: {
// targetを省略しない記述方法
on: {
click: {
target: "Active",
guard: ({ context }) => context.count < context.maxCount,
},
},
},
Active: {
entry: assign({
count: ({ context }) => context.count + 1,
}),
// targetを省略した記述方法
on: { click: "Inactive" },
after: { 2000: "Inactive" },
},
},
});
tsc
で型チェックします。エラーがしっかり出るようになります。
$ pnpm run typecheck
> next-xstate-tutorial@0.1.0 typecheck /Users/hayato94087/Private/next-xstate-tutorial
> tsc
src/components/toggle-state.tsx:12:31 - error TS2322: Type '"clickaa"' is not assignable to type '"click"'.
12 onClick={() => send({ type: "clickaa" })}
~~~~
src/machines/toggle-machine.ts:9:7
9 type: "click";
~~~~
The expected type comes from property 'type' which is declared here on type '{ type: "click"; }'
Found 1 error in src/components/toggle-state.tsx:12
エラーは修正します。
"use client";
import { useMachine } from "@xstate/react";
import { toggleMachine } from "@/machines/toggle-machine";
export const ToggleState = () => {
const [state, send] = useMachine(toggleMachine);
return (
<>
<div>{JSON.stringify(state.value)}</div>
<div>{state.context.count}</div>
<button
- onClick={() => send({ type: "clickaa" })}
+ onClick={() => send({ type: "click" })}
className="bg-blue-200 rounded-md py-1 px-3 text-slate-800 text-base"
>
Toggle
</button>
</>
);
};
コミットします。
$ git add .
$ git commit -m "add types"
まとめ
この記事では、XState の基本的な使い方を紹介しました。ステートマシンを使うことで、アプリケーションの状態をモデル化し、状態遷移を管理できます。ステートマシンを使うことで、アプリケーションの振る舞いを理解しやすくなり、コードの品質を向上させることができます。
作業リポジトリはこちらです。
Discussion