弁護士ドットコム株式会社 Creators’ blog

弁護士ドットコムがエンジニア・デザイナーのサービス開発事例やデザイン活動を発信する公式ブログです。

UIデザインにおけるステートマシン

はじめに

弁護士ドットコム デザイナーの林(@taka_piya)です。 弁護士ドットコム 案件管理システムでは、アプリケーションとUIの状態管理にXStateを用いたステートマシンでの管理を導入しています。

この記事では、UIデザインの考え方にステートマシンを導入し、実装まで一気通貫で行う方法と、そのメリットについて説明します。

UIは2つの要素からなっている

Android デベロッパーのドキュメントにある一文を紹介します。

UI は、画面上の UI 要素と UI 状態を足し合わせたものです。

ユーザーが目にするものが UI であるとすれば、ユーザーが目にするべきであるとアプリがみなすものが UI 状態です。同じコインの両面のように、UI は UI 状態を視覚的に表したものです。UI 状態が変更されると、すぐに UI に反映されます。

引用元:UI レイヤ | Android デベロッパー

UIは表示要素と状態からなるというこの定義は、Androidアプリに限らず、デジタルプロダクトのUI全般で同様に言えるでしょう。

今回は、UIからUI状態を分離しステートマシンで管理するという試みです。

ステートマシンを理解する

定義

ステートマシンとは、状態の集合と、 初期状態入力入力によって引き起こされる別の状態への遷移をモデル化したものです。*1

中でも、次に遷移すべき状態が常に一意に定まるものは決定性有限ステートマシンと呼び、今回は決定性有限ステートマシンであることを前提にします。

定義だけ聞くと難しそうですが、次のこの状態遷移図を見ると理解が進むでしょう。

状態遷移図

ステートマシンは挙動のモデルなので、様々なかたちで表現できます。*2

状態遷移図とはステートマシンの挙動を図示したものです。

鍵式のコインロッカーが並んだ写真
鍵式のコインロッカー

駅でよく見る鍵式コインロッカーを例に考えてみます。 決められた枚数コインを入れると施錠できるようになり、ひねれば施錠し、戻せば解錠される、このサービスを1度は使った経験があると思います。

このコインロッカーの挙動をステートマシンとして捉え、状態遷移図を表すと以下のようになります。

コインロッカーの挙動を表したステートマシンです。未入金、入金済、ロックの3つの状態があります。初期状態は「未入金」状態。入金という入力(イベント)によって「入金済」状態に遷移します。 施錠すると「ロック」状態になり、解錠すると「未入金」状態に戻ります。
コインロッカーのステートマシンの状態遷移図(ここでは簡単にして1枚のコインが入れば良いとしました)

  • 未入金、入金済、ロックの3つの状態があります。
  • 初期状態は「未入金」状態からスタートし、入金という入力(イベント)によって「入金済」状態に遷移します。 「未入金」状態では鍵をかけようとしてもかかりません(遷移しない)。
  • 同様に「入金済」状態で入金を繰り返しても、状態としては変わりません。
  • 施錠すると「ロック」状態になり、解錠すると「未入金」状態に戻ります。

どうでしょう。この図から、コインロッカーの挙動が浮かんできますよね。

さらに、個々の状態が1画面に対応すると考えると、画面遷移図にも近いように見えませんか。 画面遷移図との違いは、画面の役割でデザインをしているのではなく、状態に基づくデザインをしていると捉え直すことで、最小のUIパターン数で網羅できる点です。

ステートマシンを使ったUIデザイン〜実装プロセス

それでは実際の作成手順を追っていきましょう。

ステートマシンを定義する

ステートマシンの定義を最初に行うことが必要です。 ここでは、業務系システムでよくあるレコードを追加するモーダルUIを例に考えていきます。

状態と入力を書き出すと以下のようになります。このときUI上の入力だけではなくAPI通信など他の入力によって起きる状態も含めて書き出すことで、想定すべき状態が洗い出せます。 この定義のプロセスを踏むことが一番の鍵です。

レコードを追加するモーダルの状態遷移図

今回は5状態を想定しました。

  • idle - モーダルが非表示
  • open - モーダルが表示
  • pending - 通信中
  • success - 送信完了
  • failed - 通信失敗

Figmaで表現する

次に書き出した個々の状態に対するUIを当て込んでいきます。

状態と入力を先に書き出しておいたことによって、その入力を引き起こす要素を意識しながら検討できます。

状態遷移図の状態部分に、UIを当て込んだときのイメージ

実際にデザインする際は、Figmaでは1つの状態に対し1つのFrameもしくはVariablesを対応させるとわかりやすいです。

JavaScriptで表現する

同様にコードでステートマシンを実装します。

今回はTypeScriptとXStateを利用しますが、VanillaなJavaScriptでも実装できます。

まずはステートマシンの入力を定義します。

type Events =
  | { type: "OPEN" }
  | { type: "CLOSE" }
  | { type: "SEND" }
  | { type: "RESOLVE" }
  | { type: "REJECT" }
  | { type: "DONE" }
  | { type: "RETRY" };

そして状態と初期状態を定義します。

import { createMachine } from 'xstate';

const modalMachine = createMachine({
  id: 'modal',
  schema: {
    events: {} as Events, // イベント定義を参照する
  },
  initial: 'idle', // 初期状態
  states: { // 状態の集合
    idle:{},
    open:{},
    pending:{},
    success:{},
    failed:{}
  }
})

最後に入力に対する状態の遷移を定義します。

const machine = createMachine(
  {
    id: "FormModal",
    initial: "idle",
    schema: {
      events: {} as Events,
    },
    states: {
      idle: {
        on: {
          OPEN: "open", //入力に対する遷移先を設定する
        },
      },
      open: {
        on: {
          CLOSE: "idle",
          SEND: "pending",
        },
      },
      pending: {
        on: {
          RESOLVE: "idle",
          REJECT: "failed",
        },
      },
      success: {
        on: {
          DONE: "idle",
        },
      },
      failed: {
        on: {
          RETRY: "pending",
        },
      },
    },
  }
);

ステートマシンをアプリケーションに適用する

Vue.jsを利用した場合のステートマシンを実際に利用するサンプルを用意してみました。 コンポーネントの出しわけを、複数のフラグの組合せではなく、v-if = state.value === "XXXX" のように1つの状態を参照することで実現しているため、フラグを組合わせた条件よりわかりやすいでしょう。

<template>
  <button @click="send({ type: 'OPEN' })">ADD ITEM</button>

  <div v-if="state.value !== 'idle'" class="AddItemModal">
    <div class="AddItemModal__inner">
      <label>NAME:<input :disabled="state.value === 'pending'" /></label>
      <div>
        <button @click="send({ type: 'CLOSE' })">CANCEL</button>
        <button
          @click="send({ type: 'SEND' })"
          :disabled="state.value === 'pending'"
        >
          {{ state.value === "pending" ? "loading" : "SEND" }}
        </button>
      </div>
    </div>
  </div>

  <!-- トースト -->
  <div v-if="state.value === 'success'" class="Toast Toast__success">
    成功!
  </div>
  <div v-if="state.value === 'failed'" class="Toast Toast__failed">失敗!</div>
</template>

<script lang="ts">
  import { defineComponent } from "vue";
  import { useMachine } from "@xstate/vue";
  import { machine } from "./path_to_machine/machine";

  export default defineComponent({
    setup() {
      const { state, send } = useMachine(machine);
      return {
        state, // ステートマシン
        send, //ステートマシンに入力を与える関数
      };
    },
  });
</script>

実際のデモはこちらをご覧ください。

デモのイメージ(詳細

UIデザインにステートマシンを導入するメリット

ステートマシンを導入するメリットをあらためて3つの視点からまとめます。

振る舞いに集中できる

例えば「フォーム変更があった場合は、閉じる入力情報を破棄していいか確認したい」と仕様追加があったときにどうしたら良いでしょう。 UIの表示をどうする? ではなく「1つ状態を追加する必要がある」「追加したその状態はどこから遷移してくるか」を検討するでしょう。

UIと状態を切り離したことで見た目やコードに左右されず、達成すべきUIの振る舞いに着目できる思考の変化が起きます。

チームの共通認識として使える

先立ってステートマシンを定義さえしておけば、その定義をFigmaでも、コードでも、物理でもシミュレートできるのは見てきたとおりです。

ステートマシンはさまざまな方法で表現できる

定義したステートマシンこそがUIの挙動、ひいてはアプリケーションの挙動を表すものとなり、共通認識として利用できます。 状態遷移図を活用すれば、デザイナー以外ともコミュニケーションが取りやすくなるでしょう。

変更に強い

XState Visualizerでコードから状態遷移図を作成

ステートマシンは計算が可能です。

計算可能ということは、定義した遷移が実現できているか、コードベースでテストしたり、コードから状態遷移図を作ることもできます。

到達してはいけない状態変更や、到達不可能な状態が存在しないかをチェックしやすいため、抜け漏れを減らすことができます。 またステートマシンに1つ状態を追加しても、アプリケーション側で入力を変えない限りは挙動が変わりません。実装されたUIも壊れにくいといえます。

おわりに

ステートマシンを使ってUI状態を分離すること、そのメリット、実現の仕方を見てきました。 重要なのは、UIをデザインするときに状態を分離し、状態もシステムとして扱うことで、考えやすくなるという点です。

思考の下敷きとして「こういう考え方もあったな」と頭の片隅においていただけると、堅牢なUIデザインに一歩近づくのではないでしょうか。

今回ご紹介した話は、トップダウンなアプローチです。そのためプロダクトの初期状態が想定できない場合などステートマシンがなかなか適用しにくいこともあります。

ぜひ、目的に合わせて試してみてください!


関連ドキュメント

ALPS-ASD

アプリケーションの挙動や語彙を表現するフォーマット(ALPS)を使ってアプリケーションの遷移図を表現するためのドキュメント生成ツール(ASD)です。 やりたいことは近いと思います。

先日のPHP Conferenceでも触れられていますので、ぜひこちらもご覧ください。 creators.bengo4.com

*1:厳密には受理状態も含まれますが、今回は登場しません。

*2:参考: Wikipedia 有限オートマトン#表現