🗂

XState の簡易チュートリアル

2024/04/27に公開

概要

XState とは、JavaScript でステートマシンを実装するライブラリーです。ステートマシンを使ってアプリケーションの状態をモデル化し、状態遷移を管理できます。

ステートマシンを使うメリットはこちらです。

  • ありえない状態を排除できる
  • 状態、入力、遷移を明確化し、システムの振る舞いを理解できる

この記事では公式の「Get Started」の「Quick Start」を参照しながら XState を使い、有限ステートマシンを作成し、Toggle ボタンを作成する方法を紹介します。

https://stately.ai/docs/quick-start

ステートマシンとは何か

「ステートマシン」と省略していますが、正確に記述すると「有限ステートマシン」です。

有限ステートマシンとは有限な数の状態(ステート)と状態遷移(トランジション)を表現したモデルです。ある時点では1つの状態しかとらず、何かしらの入力(インプット)があると、別の状態に遷移します。

例1)ボールペンの例

例えばボールペンをイメージしてみましょう。ボールペンのペンはボタンを押すことでインクが出るようになっています。ボールペンの状態をステートマシンで表現すると以下のようになります。

  • 状態

    • ペン先が出ている
    • ペン先が出ていない
  • 入力

    • ボタンをクリック
  • 遷移

    • ペン先が出ている状態でボタンをクリックすると、ペンが出ていない状態に遷移する
    • ペン先が出ていない状態でボタンをクリックすると、ペンが出ている状態に遷移する

状態遷移図

「状態遷移図」で表現するとこのようになります。

Alt text

状態遷移表

「状態遷移表」を用いることで、状態遷移の規則を表に表す事ができます。

現在の状態 入力 次の状態
ペン先が出ている ボタンをクリック ペン先が出ていない
ペン先が出ていない ボタンをクリック ペン先が出ている

例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の内容を以下のように上書きします。

src/styles/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

初期ページ

app/page.tsxを上書きします。

src/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を上書きします。

src/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の設定を上書きします。

tailwind.config.ts
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の設定を上書きします。

tsconfig.json
{
  "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"]
}

スクリプトを追加

型チェックのスクリプトを追加します。

package.json
{
  "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 マシンは ActiveInactive の 2 つの状態を持ち、toggle イベントで状態を切り替えます。

$ mkdir -p src/machines
$ touch src/machines/toggle-machine.ts
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 に遷移します。

src/machines/toggle-machine.ts
{
    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 の状態を可視化します。

こちらからインストールします。

https://marketplace.visualstudio.com/items?itemName=statelyai.stately-vscode

Alt text

VSCode の拡張機能をおすすめとして設定します。

$ mkdir -p .vscode
$ touch .vscode/extensions.json
.vscode/extensions.json
{
  "recommendations": ["statelyai.stately-vscode"]
}

toggle-machine.ts を開くと、「Open Visual Editor」が追加されています。

alt text

クリックすると、ステートマシンを XState Editor で確認できます。

  • ①ステートマシンの状態が表示されています。
  • ②ソースコードの方にコメントが追加されています。これは、XState Editor で編集した内容をソースコードに反映するための情報が記載されています。公式のコメントはこちら
  • ③XState Editor のメニューで Edit を選択すると、ステートマシンを編集できます。
  • ④XState Editor のメニューで Simulate を選択すると、ステートマシンをシミュレーションできます。

alt text

シミュレーションの参考例はこちらです。

alt text

コミットします。

$ git add .
$ git commit -m "feat:add xstate visual editor"

ステートマシンをReactで利用

ステートマシンを Client Component で利用します。

$ mkdir -p src/components
$ touch src/components/toggle-state.tsx
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 イベントが送信され、状態が ActiveInactive で切り替わります。

      <button
        onClick={() => send({ type: "toggle" })}
        className="bg-blue-200 rounded-md py-1 px-3 text-slate-800 text-base"
      >
        Toggle
      </button>

page.tsx を編集します。

src/pages/index.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

alt text

コミットします。

$ git add .
$ git commit -m "feat:add toggle state component"

Delayed Transitions

Delay transitionsを利用することで、ステートマシンの遷移を指定の時間の経過後に自動的に行うことができます。以下の例では、Inactive から Active に遷移すると、2 秒後に Inactive へ自動的に遷移します。

src/machines/toggle-machine.ts
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

alt text

コミットします。

$ git add .
$ git commit -m "feat:add delayed transitions"

Context

Contextでステートマシンの状態を保持できます。

contextcount の変数を追加します。さらに、Active へ遷移すると count はインクリメントさせるために、entry を追加します。entry では該当の状態へ入るときに実行されるアクションを指定できます。

src/machines/toggle-machine.ts
-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 を表示します。

src/components/toggle-state.tsx
"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

alt text

コミットします。

$ git add .
$ git commit -m "feat:add context"

guard

guard は遷移を許可または拒否するための関数です。

contextmaxCount を追加し count の最大値を 3 にします。guardcount の値が 3 以上の場合は状態遷移を拒否します。

src/machines/toggle-machine.ts
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

alt text

コミットします。

$ git add .
$ git commit -m "feat:add guard"

types

現状型安全ではありまんせん。

存在しないイベント名に変更します。

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>
      <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

イベントの型を追加します。

src/machines/toggle-machine.ts
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

エラーは修正します。

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>
      <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 の基本的な使い方を紹介しました。ステートマシンを使うことで、アプリケーションの状態をモデル化し、状態遷移を管理できます。ステートマシンを使うことで、アプリケーションの振る舞いを理解しやすくなり、コードの品質を向上させることができます。

作業リポジトリはこちらです。

https://github.com/hayato94087/next-xstate-tutorial

Discussion