📘

XState で Counter を作成

2024/05/07に公開

はじめに

この記事では useState を利用した方法と比較しながら XState を使って Counter アプリを作成する方法を紹介します。流れとしては、まずはじめに useState を利用した Counter を作成し、その後 XState を利用した Counter を作成します。

https://xstate.js.org/

所感

useState と XState での実装を比較した場合、記述したコード量に差異はありません。今回の Counter の例では状態が 1 つしか存在しないため、XState の特徴であるステートマシンによる状態管理の優位性を十分発揮できません。また、useState のほうが普段から使い慣れていることもあり、今回の場合においては XState はオーバースペックとなる可能性があります。

もし、状態が複数ある場合には XState を利用することで、状態遷移を明示的に定義でき、状態遷移に関するロジックを分離できます。また、XState は状態遷移を視覚的に表現できるため、状態遷移の理解を助けることができます。

alt text

Next.jsプロジェクトの作成

動作確認するための Next.js プロジェクトを作成します。長いので、折り畳んでおきます。

新規プロジェクト作成と初期環境構築の手順詳細

プロジェクトの作成

create next-app@latestでプロジェクトを作成します。

$ pnpm create next-app@latest next-xstate-counter --typescript --eslint --import-alias "@/*" --src-dir --use-pnpm --tailwind --app
$ cd next-xstate-counter

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:新規にプロジェクトを作成し, 作業環境を構築"

useState を利用したCounterを作成

ここでは useState を利用し Counter を作成します。

useState を利用したhooks を作成

useCounter というカスタムフックスを作成します。(カスタムフックスについては公式ドキュメントを参照してください)

$ mkdir -p src/hooks
$ touch src/hooks/use-counter.ts
src/hooks/use-counter.ts
import { useState } from "react";

type Props = {
  initialCount?: number;
};

type ReturnType = [number, {
  add: (value:number) => void;
  subtract: (value:number) => void;
  set: (value:number) => void;
}];

export const useCounter = ({ initialCount = 0 }: Props): ReturnType => {
  const [count, setCount] = useState(initialCount);

  const add = (value:number) => setCount((prevCount) => prevCount + value);
  const subtract = (value:number) => setCount((prevCount) => prevCount - value);
  const set = (value:number) => setCount(value);

  return [ count, {add, subtract, set} ];
};

コンポーネントを作成

Counter コンポーネントを使用します。

$ mkdir -p src/components
$ touch src/components/counter-usestate.tsx
src/components/counter-usestate.tsx
"use client";

import { useCounter } from "@/hooks/use-counter";
import { FormEventHandler, type FC, useState, ChangeEventHandler } from "react";

type Props = {
  initialCount?: number;
};

export const Counter: FC<Props> = (props) => {
  // 作成したカスタムフックスの useCounter でカウンターの状態を管理
  const [count, { add, subtract, set }] = useCounter({
    initialCount: props.initialCount,
  });
  // リセット用の値を useState で管理
  const [resetValue, setResetValue] = useState("");

  // TextField の値が変更された時に呼ばれる関数。常に状態を更新
  const handleResetValueChange: ChangeEventHandler<HTMLInputElement> = (
    event
  ) => {
    setResetValue(event.target.value);
  };

  // 「入力値で設定」ボタンをクリック時に呼ばれる関数
  // 入力された値が数値でない場合、エラーメッセージを表示します。
  const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
    event.preventDefault();
    const resetValueNumber = Number(resetValue);
    if (isNaN(resetValueNumber) || resetValue.trim() === "") {
      alert("値が不正です。数値を入力してください。");
      return;
    }
    set(resetValueNumber);
  };

  return (
    <div className="px-4 py-3 border-2 flex flex-col space-y-4 mx-2 mt-2">
      <h1 className="text-lg font-bold text-slate-800">
        useState を利用した Counter
      </h1>
      <div>
        <span className="px-2 py-1 bg-blue-100 text-sm rounded-md text-slate-700">
          count = {count}
        </span>
      </div>
      <div className="flex flex-col space-y-2 bg-slate-100 p-2">
        <span className="text-sm bg-slate-200 px-2 py-1 text-slate-800">
          初期値
        </span>
        <div className="flex flex-row space-x-2">
          <span className="px-2 py-1 bg-blue-100 text-sm rounded-md text-slate-700">
            {props.initialCount || 0}
          </span>
        </div>
      </div>
      <div className="flex flex-col space-y-2 bg-slate-100 p-2">
        <span className="text-sm bg-slate-200 px-2 py-1 text-slate-800">
          減らす
        </span>
        <div className="flex flex-row space-x-2">
          <button
            onClick={() => {
              subtract(2);
            }}
            className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white"
          >
            - 2
          </button>
          <button
            onClick={() => {
              subtract(1);
            }}
            className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white"
          >
            - 1
          </button>
        </div>
      </div>
      <div className="flex flex-col space-y-2 bg-slate-100 p-2">
        <span className="text-sm bg-slate-200 px-2 py-1 text-slate-800">
          増やす
        </span>
        <div className="flex flex-row space-x-2">
          <button
            onClick={() => {
              add(1);
            }}
            className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white"
          >
            + 1
          </button>
          <button
            onClick={() => {
              add(2);
            }}
            className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white"
          >
            + 2
          </button>
        </div>
      </div>
      <div className="flex flex-col space-y-2 bg-slate-100 p-2">
        <span className="text-sm bg-slate-200 px-2 py-1 text-slate-800">
          値を設定
        </span>
        <button
          onClick={() => {
            set(0);
          }}
          className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white w-28"
        >
          0 にリセット
        </button>
        <form onSubmit={handleSubmit}>
          <input
            className="border-2 text-sm pl-1 w-20"
            value={resetValue}
            onChange={handleResetValueChange}
          ></input>
          <button
            type="submit"
            className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white ml-2"
          >
            入力値で設定
          </button>
        </form>
      </div>
    </div>
  );
};

コンポーネントを利用

src/pages/index.tsx を上書きします。

src/pages/index.tsx
import { Counter as CounterByUseState } from "@/components/counter-usestate";

import { type FC } from "react";

const Home: FC = () => {
  return (
    <div className="grid grid-row-2 mb-2">
      <CounterByUseState initialCount={10} />
    </div>
  );
};

export default Home;

動作確認

ローカルで動作確認します。

$ pnpm run dev

問題なく動作します。

https://twitter.com/hayato94087/status/1787568723139903872

コミットします。

$ git add .
$ git commit -m "feat: add usestate counter"

XState を利用した Counter を作成

XState をインストール

XState をインストールします。

$ pnpm install xstate @xstate/react

ステートマシンを作成

$ mkdir -p src/machines
$ touch src/machines/counter.ts
src/machines/counter.ts
import { createMachine, assign, setup } from 'xstate';

type Context = {
  count: number;
}

type Event =  { type: "SET"; value: number } | {type: "ADD"; value:number} | {type: "SUBTRACT"; value:number}| {type: "SET"; value:number};

type Input = {
  initialCount?: number
};

export const counterMachine = setup({
  types: {
    context:{} as Context,
    events: {} as Event,
    input: {} as Input,
  },
}).createMachine({
  context: ({ input }) => ({
    count: input.initialCount || 0
  }),
  on: {
    ADD: {
      actions: assign({
        count: ({ context, event }) => context.count + event.value,
      }),
    },
    SUBTRACT: {
      actions: assign({
        count: ({ context, event }) => context.count - event.value,
      }),
    },
    SET: {
      actions: assign({
        count: ({event}) => event.value,
      }),
    }
  },
});

ポイントを解説します。

setuptypes で型を定義しています。

...

type Context = {
  count: number;
}

type Event =  { type: "SET"; value: number } | {type: "ADD"; value:number} | {type: "SUBTRACT"; value:number}| {type: "SET"; value:number};

type Input = {
  initialCount?: number
};

export const counterMachine = setup({
  types: {
    context:{} as Context,
    events: {} as Event,
    input: {} as Input,
  },
}).createMachine({
  ...
});

count というカウンターの状態を管理するためのコンテキストを定義しています。count の初期値は input を通し、外部から設定できるようにしています。

...

type Input = {
  initialCount?: number
};

export const counterMachine = setup({
  types: {
    ...
    input: {} as Input,
  },
}).createMachine({
  context: ({ input }) => ({
    count: input.initialCount || 0
  }),
  ...
});

ステートは 1 つしか無く、ADD, SUBTRACT, SET のイベントを介してカウンターの状態を変更します。

...

export const counterMachine = setup({
  ...
}).createMachine({
  ...
  on: {
    ADD: {
      actions: assign({
        count: ({ context, event }) => context.count + event.value,
      }),
    },
    SUBTRACT: {
      actions: assign({
        count: ({ context, event }) => context.count - event.value,
      }),
    },
    SET: {
      actions: assign({
        count: ({event}) => event.value,
      }),
    }
  },
});

視覚的に VSCode のプラグインでステートマシンを確認できます。(詳しくはこちらを参照ください)

alt text

コンポーネントを作成

Counter コンポーネントを使用します。

$ mkdir -p src/components
$ touch src/components/counter-xstate.tsx
src/components/counter-xstate.tsx
"use client";

import { useMachine } from "@xstate/react";
import { counterMachine } from "@/machines/counter";
import { FormEventHandler, type FC, useState, ChangeEventHandler } from "react";

type Props = {
  initialCount?: number;
};

export const Counter: FC<Props> = (props) => {
  // XStateのステートマシンで useCounter でカウンターの状態を管理
  const [state, send] = useMachine(counterMachine, {
    input: { initialCount: props.initialCount },
  });
  // リセット用の値を useState で管理
  const [resetValue, setResetValue] = useState("");

  // TextField の値が変更された時に呼ばれる関数。常に状態を更新
  const handleResetValueChange: ChangeEventHandler<HTMLInputElement> = (
    event
  ) => {
    setResetValue(event.target.value);
  };

  // 「入力値で設定」ボタンをクリック時に呼ばれる関数
  // 入力された値が数値でない場合、エラーメッセージを表示します。
  const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
    event.preventDefault();
    const resetValueNumber = Number(resetValue);
    if (isNaN(resetValueNumber) || resetValue.trim() === "") {
      alert("値が不正です。数値を入力してください。");
      return;
    }
    send({ type: "SET", value: resetValueNumber });
  };

  return (
    <div className="px-4 py-3 border-2 flex flex-col space-y-4 mx-2 mt-2">
      <h1 className="text-lg font-bold text-slate-800">
        XState を利用した Counter
      </h1>
      <div>
        <span className="px-2 py-1 bg-blue-100 text-sm rounded-md text-slate-700">
          count = {state.context.count}
        </span>
      </div>
      <div className="flex flex-col space-y-2 bg-slate-100 p-2">
        <span className="text-sm bg-slate-200 px-2 py-1 text-slate-800">
          初期値
        </span>
        <div className="flex flex-row space-x-2">
          <span className="px-2 py-1 bg-blue-100 text-sm rounded-md text-slate-700">
            {props.initialCount || 0}
          </span>
        </div>
      </div>
      <div className="flex flex-col space-y-2 bg-slate-100 p-2">
        <span className="text-sm bg-slate-200 px-2 py-1 text-slate-800">
          減らす
        </span>
        <div className="flex flex-row space-x-2">
          <button
            onClick={() => {
              send({ type: "SUBTRACT", value: 2 });
            }}
            className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white"
          >
            - 2
          </button>
          <button
            onClick={() => {
              send({ type: "SUBTRACT", value: 1 });
            }}
            className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white"
          >
            - 1
          </button>
        </div>
      </div>
      <div className="flex flex-col space-y-2 bg-slate-100 p-2">
        <span className="text-sm bg-slate-200 px-2 py-1 text-slate-800">
          増やす
        </span>
        <div className="flex flex-row space-x-2">
          <button
            onClick={() => {
              send({ type: "ADD", value: 1 });
            }}
            className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white"
          >
            + 1
          </button>
          <button
            onClick={() => {
              send({ type: "ADD", value: 2 });
            }}
            className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white"
          >
            + 2
          </button>
        </div>
      </div>
      <div className="flex flex-col space-y-2 bg-slate-100 p-2">
        <span className="text-sm bg-slate-200 px-2 py-1 text-slate-800">
          値を設定
        </span>
        <button
          onClick={() => {
            send({ type: "SET", value: 0 });
          }}
          className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white w-28"
        >
          0 にリセット
        </button>
        <form onSubmit={handleSubmit}>
          <input
            className="border-2 text-sm pl-1 w-20"
            value={resetValue}
            onChange={handleResetValueChange}
          ></input>
          <button
            type="submit"
            className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white ml-2"
          >
            入力値で設定
          </button>
        </form>
      </div>
    </div>
  );
};

上記だと counter-usestate.tsx からの違いが分かりにくいので差分を示します。

"use client";

-import { useCounter } from "@/hooks/use-counter";
+import { useMachine } from "@xstate/react";
+import { counterMachine } from "@/machines/counter";
import { FormEventHandler, type FC, useState, ChangeEventHandler } from "react";

type Props = {
  initialCount?: number;
};

export const Counter: FC<Props> = (props) => {
- // 作成したカスタムフックスの useCounter でカウンターの状態を管理
- const [count, { add, subtract, set }] = useCounter({
-   initialCount: props.initialCount,
- });
+ // XStateのステートマシンで useCounter でカウンターの状態を管理
+ const [state, send] = useMachine(counterMachine, {
+   input: { initialCount: props.initialCount },
+ });
  // リセット用の値を useState で管理
  const [resetValue, setResetValue] = useState("");

  // TextField の値が変更された時に呼ばれる関数。常に状態を更新
  const handleResetValueChange: ChangeEventHandler<HTMLInputElement> = (
    event
  ) => {
    setResetValue(event.target.value);
  };

  // 「入力値で設定」ボタンをクリック時に呼ばれる関数
  // 入力された値が数値でない場合、エラーメッセージを表示します。
  const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
    event.preventDefault();
    const resetValueNumber = Number(resetValue);
    if (isNaN(resetValueNumber) || resetValue.trim() === "") {
      alert("値が不正です。数値を入力してください。");
      return;
    }
-   set(resetValueNumber);
+   send({ type: "SET", value: resetValueNumber });
  };

  return (
    <div className="px-4 py-3 border-2 flex flex-col space-y-4 mx-2 mt-2">
      <h1 className="text-lg font-bold text-slate-800">
        useState を利用した Counter
      </h1>
      <div>
        <span className="px-2 py-1 bg-blue-100 text-sm rounded-md text-slate-700">
          count = {count}
        </span>
      </div>
      <div className="flex flex-col space-y-2 bg-slate-100 p-2">
        <span className="text-sm bg-slate-200 px-2 py-1 text-slate-800">
          初期値
        </span>
        <div className="flex flex-row space-x-2">
          <span className="px-2 py-1 bg-blue-100 text-sm rounded-md text-slate-700">
            {props.initialCount || 0}
          </span>
        </div>
      </div>
      <div className="flex flex-col space-y-2 bg-slate-100 p-2">
        <span className="text-sm bg-slate-200 px-2 py-1 text-slate-800">
          減らす
        </span>
        <div className="flex flex-row space-x-2">
          <button
            onClick={() => {
-             subtract(2);
+             send({ type: "SUBTRACT", value: 2 });
            }}
            className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white"
          >
            - 2
          </button>
          <button
            onClick={() => {
-             subtract(1);
+             send({ type: "SUBTRACT", value: 1 });
            }}
            className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white"
          >
            - 1
          </button>
        </div>
      </div>
      <div className="flex flex-col space-y-2 bg-slate-100 p-2">
        <span className="text-sm bg-slate-200 px-2 py-1 text-slate-800">
          増やす
        </span>
        <div className="flex flex-row space-x-2">
          <button
            onClick={() => {
-             add(1);
+             send({ type: "ADD", value: 1 });
            }}
            className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white"
          >
            + 1
          </button>
          <button
            onClick={() => {
-             add(2);
+             send({ type: "ADD", value: 2 });
            }}
            className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white"
          >
            + 2
          </button>
        </div>
      </div>
      <div className="flex flex-col space-y-2 bg-slate-100 p-2">
        <span className="text-sm bg-slate-200 px-2 py-1 text-slate-800">
          値を設定
        </span>
        <button
          onClick={() => {
-           set(0);
+           send({ type: "SET", value: 0 });
          }}
          className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white w-28"
        >
          0 にリセット
        </button>
        <form onSubmit={handleSubmit}>
          <input
            className="border-2 text-sm pl-1 w-20"
            value={resetValue}
            onChange={handleResetValueChange}
          ></input>
          <button
            type="submit"
            className="px-2 py-1 bg-slate-800 border-slate-800 rounded-md text-sm shadow-sm hover:bg-slate-700 text-white ml-2"
          >
            入力値で設定
          </button>
        </form>
      </div>
    </div>
  );
};

コンポーネントを利用

src/pages/index.tsx を上書きします。

src/pages/index.tsx
import { Counter as CounterByUseState } from "@/components/counter-usestate";
+import { Counter as CounterByXState } from "@/components/counter-xstate";

import { type FC } from "react";

const Home: FC = () => {
  return (
    <div className="grid grid-row-2 mb-2">
      <CounterByUseState initialCount={10} />
+     <CounterByXState initialCount={10} />
    </div>
  );
};

export default Home;

動作確認

ローカルで動作確認します。

$ pnpm run dev

問題なく動作します。

https://twitter.com/hayato94087/status/1787568808510865815

コミットします。

$ git add .
$ git commit -m "feat: add xstate counter"

まとめ

この記事では、useState を利用した方法と比較しながら XState を使って Counter アプリを作成する方法を紹介しました。XState を使うことで、状態遷移を明示的に定義でき、状態遷移に関するロジックを分離できます。また、XState は状態遷移を視覚的に表現できるため、状態遷移の理解を助けることができます。が、今回の例では状態が 1 つしか存在しないため、useState のほうが普段から使い慣れていることもあり、XState はオーバースペックとなる可能性があります。

作業リポジトリとはこちらになります。

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

Discussion