自動販売機のステートマシンを作る
それでは、ビジュアライザーに書き込む形で、ゼロからステートマシンを作る手順を追ってみましょう。図1で例示した自動販売機のステートマシンを、図4のようなUIに当てはめることを目標とします。
まずは、ステートマシンに名前をつけましょう。createMachine関数にオブジェクトを渡して定義していきます(リスト1)。
// src/vending.machine.js
import { createMachine } from "xstate";
export const machine = createMachine({
id: "vending-machine", // (1)
description: "自動販売機", // (2)
});
プログラムから認識されるステートマシンの名前は(1)の id で定義します。また、説明文を付けたい場合は(2)の description で定義します。 description は状態や遷移など、ほとんどの場所に書けるので、日本語名を書く場所としてちょうどよいでしょう。
次に、取り得る状態を列挙します。今回の自動販売機では、初期状態(idle)、お金を投入中(inserting)、商品を購入できる(ready)の三つの状態を取ります(リスト2)。
(src/vending.machine.js)
import { createMachine } from "xstate";
export const machine = createMachine({
id: "vending-machine",
description: "自動販売機",
initial: "idle", // (2)
states: { // (1)
idle: {
description: "初期状態",
},
inserting: {
description: "お金を投入中",
},
ready: {
description: "商品を購入できる",
},
},
});
(1)のように states という名前のオブジェクトを定義し、その中に状態の名前をキーとして列挙していきます。この時点ではidle: {}のように空オブジェクトを置いても問題はありませんが、わかりやすさのためにdescriptionで状態の日本語名を定義しておきました。これをビジュアライザーのエディタに入力すると、次のような見た目になります(図5)。
まだ繋がりを定義していないので、状態遷移図らしさはありませんが、ひとまず状態が並んでいるのでOKです。idle に矢印がついていますが、これはリスト2の(2)で、初期状態を指定するための initial に idle を定義したために、ステートマシンから初期状態として認識されている様子を表しています。
では次に、それぞれの状態が次のどの状態に遷移できるのかを定義します。遷移(transition)の定義は、状態のオブジェクト内に on というキーでオブジェクトを作成し、その中に定義します(リスト3)。
(src/vending.machine.js)
import { createMachine } from "xstate";
export const machine = createMachine({
// 略
states: {
idle: {
description: "初期状態",
on: {
INSERT: { description: "お金を投入する", target: "inserting" },
},
},
inserting: {
description: "お金を投入中",
on: {
INSERT: { description: "お金を投入する", target: "inserting" },
HAS_ENOUGH_AMOUNT: {
description: "投入済みの金額が商品の最低金額以上になったら、商品を購入できる",
target: "ready",
},
},
},
ready: {
description: "商品を購入できる",
on: {
BUY: { description: "商品を購入する", target: "ready" },
INSERT: { description: "お金を投入する", target: "ready" },
HAS_NOT_ENOUGH_AMOUNT: {
description: "投入済みの金額が商品の最低金額未満になったら、商品を購入できなくなる",
target: "inserting",
},
},
},
},
});
INSERTやBUYといった遷移の名前は、慣例として大文字で記載するようです。遷移の定義としては、主に遷移先の状態を表すtargetに状態の名前を記載することになります。この時点で、ビジュアライザーは図6のようになりました。
図1の状態遷移図に近い形になりました。
更新可能なデータを使って自動遷移を行う
さて、ビジュアライザーで作った状態遷移図をクリックして、状態を行き来できるようになりましたが、少し作りたかったものとは違います。本来はINSERTを行うたびに内部で金額が加算されていき、それに応じてHAS_ENOUGH_AMOUNTに相当する遷移が自動で発生してほしかったはずです。自分でクリックして遷移を発生させるのは本意ではありません。
XStateのステートマシンには、「内部で加算していく金額」のような概念を扱うための、コンテキスト(context)という仕組みがあります。自動販売機への投入金額を、このコンテキストで管理してみましょう(リスト4)。
(/src/vending.machine.js)
import { createMachine, assign } from "xstate"; // (2)
export const machine = createMachine(
{
id: "vending-machine",
description: "自動販売機",
initial: "idle",
context: {
// 投入した金額
amount: 0, // (1)
// 最後に購入した商品の名前
boughtItem: null,
},
states: {
idle: {
description: "初期状態",
on: {
INSERT: {
description: "お金を投入する",
target: "inserting",
actions: ["insert"], // (7)
},
},
},
inserting: {
description: "お金を投入中",
always: { // (8)
description:
"投入済みの金額が商品の最低金額以上になったら、商品を購入できる",
target: "ready",
cond: "hasEnoughAmount", // (9)
},
on: {
INSERT: { description: "お金を投入する", target: "inserting", actions: ["insert"] },
},
},
ready: {
description: "商品を購入できる",
always: {
description:
"投入済みの金額が商品の最低金額未満になったら、商品を購入できなくなる",
target: "inserting",
cond: "hasNotEnoughAmount",
},
on: {
BUY: { description: "商品を購入する", target: "ready", actions: ["buy"] },
INSERT: { description: "お金を投入する", target: "ready", actions: ["insert"] },
},
},
},
},
{
actions: { // (3)
// お金を投入したらamountを加算する
insert: assign((context, event) => { // (4)
// eventは { amount: 100 } のような投入金額についてのオブジェクト
return {
// ビジュアライザーで動かしたときは常に100円が入るようデフォルト値を設定している
amount: context.amount + (event.amount ?? 100), // (5)
};
}),
// 商品を購入したらamountを減算する
buy: assign((context, event) => {
// eventは { name: "コーラ", price: 100 } のような商品についてのオブジェクト
return {
// ビジュアライザーで動かしたときは常に100円で買ったことになるようデフォルト値を設定している
amount: context.amount - (event.price ?? 100),
// 最後に購入した商品の名前
boughtItem: event.name,
};
}),
},
guards: { // (6)
// 投入金額が100円以上ならOK
hasEnoughAmount: (context) => {
return context.amount >= 100;
},
// 投入金額が100円未満ならOK
hasNotEnoughAmount: (context) => {
return context.amount < 100;
},
},
}
);
まずは、ステートマシン定義の一番上の階層にcontextというキーでオブジェクトを定義し、管理したいデータの初期値を(1)のように設定します。
次に、createMachineの第2引数としてオブジェクトを定義し、その中にactionsというオブジェクトを定義します(3)。これは、なんらかのイベント(遷移など)が発生した際に呼び出して、コンテキストを更新するための関数を置いておくための場所です。(2)のようにassign関数をインポートして、(4)で関数を渡しています。(4)で渡している関数は、第1引数が現在のコンテキストの値、第2引数がイベントに紐づいたデータです。第2引数のイベントデータは、アプリケーションに組み込んで実行する際にはパラメータとして渡せるのですが、ビジュアライザーからは渡す方法がないため、今回は(5)のようにデフォルト値を用意しました。
続いて、actionsの隣に(6)のようにguardsというオブジェクトを作ります。こちらには、コンテキスト内のデータの状況を監視して自動で遷移を行うための判定関数を書きます。今回は投入金額が商品の最低金額(100円)以上か未満かを判定する関数をそれぞれ書きました。
最後に、actionsやguardsに定義した関数を呼び出します。actionsに定義した関数は遷移イベントのときに実行されてほしいので、(7)のように遷移の定義内にactionsという名前で関数名を追加します。また、自動で状態遷移する設定を行うには(8)のように状態の定義にalwaysというオブジェクトを定義します。alwaysオブジェクトには、発動条件を表すcondというプロパティが(9)のように定義でき、ここにguardsで定義した判定関数の名前を当てはめます。
これでステートマシンとしては完成です。最終的に、ビジュアライザー上では図7のようになりました。
右ペインの「State」タブを開いた状態で操作すると、操作するたびにcontextの値が増えたり減ったりするのが確認できるかと思います。
ReactからXState製のステートマシンを使う
さて、ステートマシンが完成したので、Reactで動かしてみましょう。といっても、やることはさほど多くありません。まずは、Reactのプロジェクトに、xstateと@xstate/reactをインストールします。
$ npm install xstate @xstate/react
準備としてはこれで完了です。本記事のサンプルはCreate React Appで作成していますが、Next.jsなどでも使い方は特に変わりません。
図6で紹介していた自動販売機のUIの中で、XStateを利用したものがリスト7です。スタイルのApp.cssはサンプルコードに含まれているので、手元で実行する場合はコピーしてお使いください。
(src/App.js)
import { useMachine } from "@xstate/react";
import "./App.css";
import { machine } from "./vending.machine"; // (1)
export const items = [
{ id: "coke350", name: "コーラ", size: "350ml", price: 100 },
{ id: "water500", name: "おいしい水", size: "500ml", price: 100 },
{ id: "orange350", name: "オレンジ", size: "350ml", price: 100 },
];
function App() {
const [state, send] = useMachine(machine); // (2)
const { context } = state; // (3)
return (
<div className="App">
<h1>Vending Machine</h1>
<div className="grid">
{items.map((item) => (
<div key={item.id} className="item container">
<div>{item.name}</div>
<button
onClick={() => send("BUY", item)} // (6)
disabled={!state.matches("ready")} // (7)
>
{item.price}円
</button>
</div>
))}
<div className="insert-span" />
<div className="insert">
<div>
<h3>投入金額</h3>
<div style={{ fontSize: 24 }}>{context.amount} Yen</div>{/* (4) */}
</div>
<div style={{ marginTop: 16 }}>
<button onClick={() => send("INSERT", { amount: 100 })}>{/* (5) */}
INSERT 100 Yen
</button>
</div>
</div>
<div className="output">
<h3>Output:</h3>
<div className="output-item">{context.boughtItem}</div>{/* (4) */}
</div>
</div>
</div>
);
}
export default App;
まずは、作成済みのステートマシンを vending.machine.jsというファイル名でコンポーネントと同じ階層に配置し、(1)のようにインポートします。(1)でインポートしたmachineオブジェクトは、(2)で@xstate/reactのuseMachineフックに読み込ませます。useMachineの戻り値は配列で、第1要素には現在状態が入っており、第2要素には遷移イベントを起こすための関数が入っています。それぞれ、state、sendという名前にしました。
また、現在状態に含まれているコンテキストを後で使いたいので(3)でコンテキストを取り出して、(4)で表示に使っています。
遷移イベントはお金を投入するボタン(5)や購入するボタン(6)のように、第1引数へステートマシンのonで定義していた遷移の名前を渡します。第2引数に渡したデータは、ステートマシンのactionsでイベントデータとして利用されます。
ステートマシンらしさが特に出ているのが(7)で購入ボタンの使用可・不可を設定しているところです。state.matchesはステートマシン内の状態の名前を渡すことで、現在状態がその状態になっているか判定できる関数です。(7)では「商品を購入できる」の状態になっているかどうかをチェックしており、それ以外の状態になっている間はボタンを利用不可にしてあります。
コンポーネント側でデータを扱っているのはこれだけです。状態管理のほとんどがステートマシン側で完結してるので、コンポーネントは現在状態の表示と、遷移イベントの呼び出しだけを行えばよいのです。というわけで、ロジックの大半をステートマシンに実装した自動販売機ができました(図8)。
「INSERT 100 Yen」ボタンを押して投入金額を増やしたり、商品を買って金額が減る様子をお楽しみください。
まとめ
XStateで状態管理をする簡単な例を解説しました。UI側で状態管理を行わずにステートマシンに任せることで、状態管理由来の不具合を減らすことができる可能性があります。
次回はXStateの基本的なAPIについて解説します。
