SHOEISHA iD

※旧SEメンバーシップ会員の方は、同じ登録情報(メールアドレス&パスワード)でログインいただけます

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

現場で役立つ! React向けライブラリ詳説

ステートマシンを定義して状態管理を行うライブラリ「XState」を解説

現場で役立つ! React向けライブラリ詳説 第10回

  • X ポスト
  • このエントリーをはてなブックマークに追加

自動販売機のステートマシンを作る

 それでは、ビジュアライザーに書き込む形で、ゼロからステートマシンを作る手順を追ってみましょう。図1で例示した自動販売機のステートマシンを、図4のようなUIに当てはめることを目標とします。

図4:自動販売機のUI
図4:自動販売機のUI

 まずは、ステートマシンに名前をつけましょう。createMachine関数にオブジェクトを渡して定義していきます(リスト1)。

[リスト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)。

[リスト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)。

図5:状態を例挙したところ
図5:状態を例挙したところ

 まだ繋がりを定義していないので、状態遷移図らしさはありませんが、ひとまず状態が並んでいるのでOKです。idle に矢印がついていますが、これはリスト2の(2)で、初期状態を指定するための initialidle を定義したために、ステートマシンから初期状態として認識されている様子を表しています。

 では次に、それぞれの状態が次のどの状態に遷移できるのかを定義します。遷移(transition)の定義は、状態のオブジェクト内に on というキーでオブジェクトを作成し、その中に定義します(リスト3)。

[リスト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",
        },
      },
    },
  },
});

 INSERTBUYといった遷移の名前は、慣例として大文字で記載するようです。遷移の定義としては、主に遷移先の状態を表すtargetに状態の名前を記載することになります。この時点で、ビジュアライザーは図6のようになりました。

図6:遷移を定義した
図6:遷移を定義した

 図1の状態遷移図に近い形になりました。

更新可能なデータを使って自動遷移を行う

 さて、ビジュアライザーで作った状態遷移図をクリックして、状態を行き来できるようになりましたが、少し作りたかったものとは違います。本来はINSERTを行うたびに内部で金額が加算されていき、それに応じてHAS_ENOUGH_AMOUNTに相当する遷移が自動で発生してほしかったはずです。自分でクリックして遷移を発生させるのは本意ではありません。

 XStateのステートマシンには、「内部で加算していく金額」のような概念を扱うための、コンテキスト(context)という仕組みがあります。自動販売機への投入金額を、このコンテキストで管理してみましょう(リスト4)。

[リスト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円)以上か未満かを判定する関数をそれぞれ書きました。

 最後に、actionsguardsに定義した関数を呼び出します。actionsに定義した関数は遷移イベントのときに実行されてほしいので、(7)のように遷移の定義内にactionsという名前で関数名を追加します。また、自動で状態遷移する設定を行うには(8)のように状態の定義にalwaysというオブジェクトを定義します。alwaysオブジェクトには、発動条件を表すcondというプロパティが(9)のように定義でき、ここにguardsで定義した判定関数の名前を当てはめます。

 これでステートマシンとしては完成です。最終的に、ビジュアライザー上では図7のようになりました。

図7:ステートマシン完成
図7:ステートマシン完成

 右ペインの「State」タブを開いた状態で操作すると、操作するたびにcontextの値が増えたり減ったりするのが確認できるかと思います。

ReactからXState製のステートマシンを使う

 さて、ステートマシンが完成したので、Reactで動かしてみましょう。といっても、やることはさほど多くありません。まずは、Reactのプロジェクトに、xstate@xstate/reactをインストールします。

[リスト6]Xstateをインストール
$ npm install xstate @xstate/react

 準備としてはこれで完了です。本記事のサンプルはCreate React Appで作成していますが、Next.jsなどでも使い方は特に変わりません。

 図6で紹介していた自動販売機のUIの中で、XStateを利用したものがリスト7です。スタイルのApp.cssはサンプルコードに含まれているので、手元で実行する場合はコピーしてお使いください。

[リスト7]ReactでXstateを扱う
(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/reactuseMachineフックに読み込ませます。useMachineの戻り値は配列で、第1要素には現在状態が入っており、第2要素には遷移イベントを起こすための関数が入っています。それぞれ、statesendという名前にしました。

 また、現在状態に含まれているコンテキストを後で使いたいので(3)でコンテキストを取り出して、(4)で表示に使っています。

 遷移イベントはお金を投入するボタン(5)や購入するボタン(6)のように、第1引数へステートマシンのonで定義していた遷移の名前を渡します。第2引数に渡したデータは、ステートマシンのactionsでイベントデータとして利用されます。

 ステートマシンらしさが特に出ているのが(7)で購入ボタンの使用可・不可を設定しているところです。state.matchesはステートマシン内の状態の名前を渡すことで、現在状態がその状態になっているか判定できる関数です。(7)では「商品を購入できる」の状態になっているかどうかをチェックしており、それ以外の状態になっている間はボタンを利用不可にしてあります。

 コンポーネント側でデータを扱っているのはこれだけです。状態管理のほとんどがステートマシン側で完結してるので、コンポーネントは現在状態の表示と、遷移イベントの呼び出しだけを行えばよいのです。というわけで、ロジックの大半をステートマシンに実装した自動販売機ができました(図8)。

図8:自動販売機が完成した
図8:自動販売機が完成した

 「INSERT 100 Yen」ボタンを押して投入金額を増やしたり、商品を買って金額が減る様子をお楽しみください。

まとめ

 XStateで状態管理をする簡単な例を解説しました。UI側で状態管理を行わずにステートマシンに任せることで、状態管理由来の不具合を減らすことができる可能性があります。

 次回はXStateの基本的なAPIについて解説します。

この記事は参考になりましたか?

  • X ポスト
  • このエントリーをはてなブックマークに追加
現場で役立つ! React向けライブラリ詳説連載記事一覧

もっと読む

この記事の著者

WINGSプロジェクト 中川幸哉(ナカガワユキヤ)

WINGSプロジェクトについて> 有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティ(代表 山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手がける。2018年11月時点での登録メンバは55名で、現在も執筆メンバを募集中。興味のある方は、どしどし応募頂きたい。著書記事多数。 RSS Twitter: @yyamada(公式)、@yyamada/wings(メンバーリスト) Facebook

※プロフィールは、執筆時点、または直近の記事の寄稿時点での内容です

山田 祥寛(ヤマダ ヨシヒロ)

静岡県榛原町生まれ。一橋大学経済学部卒業後、NECにてシステム企画業務に携わるが、2003年4月に念願かなってフリーライターに転身。Microsoft MVP for Visual Studio and Development Technologies。執筆コミュニティ「WINGSプロジェクト」代表。主な著書に「独習シリーズ(Java・C#・Python・PHP・Ruby・JSP&サーブレットなど)」「速習シリーズ(ASP.NET Core・Vue.js・React・TypeScript・ECMAScript、Laravelなど)」「改訂3版JavaScript本格入門」「これからはじめるReact実践入門」「はじめてのAndroidアプリ開発 Kotlin編 」他、著書多数

※プロフィールは、執筆時点、または直近の記事の寄稿時点での内容です

この記事は参考になりましたか?

この記事をシェア

  • X ポスト
  • このエントリーをはてなブックマークに追加
CodeZine(コードジン)
https://codezine.jp/article/detail/16106 2022/07/11 11:00

おすすめ

アクセスランキング

アクセスランキング

イベント

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

新規会員登録無料のご案内

  • ・全ての過去記事が閲覧できます
  • ・会員限定メルマガを受信できます

メールバックナンバー

アクセスランキング

アクセスランキング