👻

【XState】ステートマシンライブラリでreact-hook-formのバリデーターを作る

2023/11/19に公開

はじめに

この記事ではXStateというステートマシン(有限オートマトン)のライブラリの使い方を紹介します

現状XStateに関する日本語の記事はほとんどありません

国内での認知が低い理由として、

  • 学習コストが高く導入が難しい
  • 記事も少ないためインプットが難しい
  • 実際にある程度使ってみないと慣れるのに時間がかかる
  • 実務レベルのサンプルが公式にない(デファクトスタンダードis何?)

があります

僕自身もある程度感覚を掴み始めたレベルですので参考程度に読んでいただければと思います

また、Zennに本として公式ドキュメントを飜訳したりTipsなどを書いているものもあるので事前に目を通してからこの記事を読んでいただけると理解しやすいかと思います

JS/TSで堅牢な状態管理を可能にするXStateの解説

XStateとは

  • ステートマシンのモデリング概念をJavaScript/TypeScriptで記述できる
  • React, Vueなどの関数を状態遷移時の副作用として
  • ステートマシン内にContextを持つことができる
    • Reduxでいうstoreのような空間をマシンごとに持つことができます

react-hook-formのバリデーション機能を再現してみる

先述の通り実務レベルのサンプルがなく、つらつらと公式のサンプルコードを見せても実際どう使えばいいんだという気持ちになってしまいます

そこで、タイトルにもある通り皆さんがある程度認知しているライブラリを再現して解説していきます

サンプルのゴール

今回のサンプルでは下記をゴールとして作成します

  • 外部からバリデーターを指定できる

  • mode"onBlur" | "onChange" | "onTouched" | "all"を指定してバリデーションタイミングを制御できる

    • React Hook Form > useForm
    • onBlur: onblurイベント時にバリデーションが実行される
    • onChange: onchangeイベント時にバリデーションが実行される
    • onTouched: 初回はonblurイベント時に、それ以降はonchange/onblurイベントどちらでもバリデーションが実行される
    • all: onchange/onblurイベントどちらでもバリデーションが実行される
  • 下記の状態を取得できる

    • React Hook Form > useFormState
    • isDirty: 初期値から変更されたかどうか
    • isDisabled: フィールドが編集不可かどうか
    • isFocusing: フィールドにフォーカスしているかどうか
    • isTouched: フィールドに1度でもフォーカスしたかどうか
    • isValidating: バリデーション実行中かどうか
    • isValidated: バリデーション実行済みかどうか
    • isValid: バリデーションをパスしたかどうか

inputのステートマシンの状態遷移図

では、完成したステートマシンの状態遷移図になります
(小さくて見にくいので後ほど拡大画像と一緒に細かく解説します)

inputMachine

なんとこのライブラリはコード↔状態遷移図を相互に変換することが可能です

上記の状態遷移図から生成されるコード
import { createMachine } from "xstate";

export const machine = createMachine(
  {
    id: "inputMachine",
    states: {
      touchState: {
        initial: "untouched",
        states: {
          untouched: {
            on: {
              FOCUS: {
                target: "touched",
              },
            },
          },
          touched: {
            type: "final",
          },
        },
      },
      dirtyState: {
        initial: "pristine",
        states: {
          pristine: {
            always: {
              target: "dirty",
              guard: "isDirty",
            },
          },
          dirty: {
            always: {
              target: "pristine",
              guard: "isPristine",
            },
          },
        },
      },
      validity: {
        initial: "invalid",
        states: {
          invalid: {
            always: {
              target: "valid",
              guard: "isValid",
            },
          },
          valid: {
            always: {
              target: "invalid",
              guard: "isInvalid",
            },
          },
        },
      },
      validationState: {
        initial: "idle",
        states: {
          idle: {
            always: {
              target: "validating",
              guard: "isAllMode",
            },
            on: {
              CHANGE: {
                target: "validating",
                guard: "isOnChangeMode",
              },
              BLUR: [
                {
                  target: "validating",
                  guard: "isOnBlurMode",
                },
                {
                  target: "validating",
                  guard: "isOnTouchedMode",
                },
              ],
            },
          },
          validating: {
            on: {
              CHANGE: [
                {
                  guard: "isAllMode",
                  target: "validating",
                },
                {
                  guard: "isOnChangeMode",
                  target: "validating",
                },
                {
                  guard: "isOnTouchedMode",
                  target: "validating",
                },
              ],
              BLUR: [
                {
                  guard: "isAllMode",
                  target: "validating",
                },
                {
                  guard: "isOnBlurMode",
                  target: "validating",
                },
              ],
            },
            invoke: {
              src: "validate",
              id: "validate",
              onDone: [
                {
                  target: "validated",
                  actions: {
                    type: "setError",
                  },
                },
              ],
            },
          },
          validated: {
            on: {
              CHANGE: [
                {
                  target: "validating",
                  guard: "isAllMode",
                },
                {
                  target: "validating",
                  guard: "isOnChangeMode",
                },
                {
                  target: "validating",
                  guard: "isOnTouchedMode",
                },
              ],
              BLUR: [
                {
                  target: "validating",
                  guard: "isAllMode",
                },
                {
                  target: "validating",
                  guard: "isOnBlurMode",
                },
              ],
            },
          },
        },
      },
      editableState: {
        initial: "enabled",
        states: {
          enabled: {
            states: {
              focusState: {
                initial: "unfocused",
                states: {
                  unfocused: {
                    on: {
                      FOCUS: {
                        target: "focused",
                      },
                    },
                  },
                  focused: {
                    on: {
                      BLUR: {
                        target: "unfocused",
                      },
                      CHANGE: {
                        target: "focused",
                        actions: {
                          type: "setValue",
                        },
                      },
                    },
                  },
                },
              },
            },
            on: {
              DISABLE: {
                target: "disabled",
              },
            },
            type: "parallel",
          },
          disabled: {
            on: {
              ENABLE: {
                target: "enabled",
              },
            },
          },
        },
      },
    },
    type: "parallel",
    types: {
      events: {} as
        | { type: "BLUR" }
        | { type: "FOCUS" }
        | { type: "CHANGE" }
        | { type: "ENABLE" }
        | { type: "DISABLE" },
    },
  },
  {
    actions: {
      setError: ({ context, event }) => {},
      setValue: ({ context, event }) => {},
    },
    actors: {
      validate: createMachine({
        /* ... */
      }),
    },
    guards: {
      isDirty: ({ context, event }, params) => {
        return false;
      },
      isPristine: ({ context, event }, params) => {
        return false;
      },
      isValid: ({ context, event }, params) => {
        return false;
      },
      isInvalid: ({ context, event }, params) => {
        return false;
      },
      isOnChangeMode: ({ context, event }, params) => {
        return false;
      },
      isOnBlurMode: ({ context, event }, params) => {
        return false;
      },
      isAllMode: ({ context, event }, params) => {
        return false;
      },
      isOnTouchedMode: ({ context, event }, params) => {
        return false;
      },
    },
    delays: {},
  },
);

ステートマシンの定義の解説

ステートマシンは createMachineを使用して作成します

プロパティはそれぞれ下記のような役割です

プロパティ名 役割
id 識別子
context storeのようなもの
type normal, final, parallel, historyが指定できます
types TypeScriptの型推論を効かせるために必要です
states 状態と状態遷移、副作用などを記載します

inputMachineはParallel Stateで下記を持ちます

  • touchState
  • dirtyState
  • editableState
  • validity
  • validationState

これらの状態について1つずつ解説します

touchState

  • Initial Stateはuntouched
  • FOCUSというEventを送るとtouchedとなる
  • touchedはFinal Stateである

inputMachine - touchState

dirtyState

  • Initial Stateはpristine
  • AlwaysでContext内のフィールドの初期値(initialValue)と現在の値(value)が異なれば遷移する

inputMachine - dirtyState

editableState

  • Initial stateは{enabled: "unfocused"}
  • DISABLE/ENABLEEventで編集可能かどうかが相互に状態遷移する
  • enabledなときはChild Stateを持つ

inputMachine - editableState

enabledのとき
  • FOCUS/BLUREventでフォーカスしているかどうかが相互に状態遷移する
  • focusedであるとき、CHANGEEventを実行してsetValueActionでContext内のvalueを更新する

inputMahine - editableState - enabled

validity

  • Initial Staetはinvalid
  • AlwaysでContext内のエラー(error)が存在するかどうかで遷移する

inputMachine - validity

validationState

  • modeによってバリデーション実行タイミングが変わる(図参照)
  • CHANGE/BLUREventをトリガーとしてvalidatingに状態遷移する
    • validatingではInvoke Actorとしてvalidateが実行され、その実行結果をsetErrorActionでContextに反映している

inputMachine - validationState

inputMachineの生成されたコードの内部を実装する

型を整理する

StatelyのEditorから生成されたコードでは型が不足しているため追記します
inputMachineの型はこのようになります

import type { PromiseActorLogic } from "xstate";
import type { ZodError, ZodTypeAny } from "zod";

type Context = {
  initialValue: string;
  // 値はstringのみを扱う
  value: string;
  required: boolean;
  // validatorの型はZodString, ZodEffects<ZodString>などがあるのでZodTypeAnyとしている
  validator: ZodTypeAny;
  // errorがある場合はZodErrorを持つ
  error: ZodError | undefined;
  mode: "onBlur" | "onChange" | "onTouched" | "all";
};

type Events =
  | { type: "FOCUS" }
  | { type: "ENABLE" }
  | { type: "DISABLE" }
  | { type: "BLUR" }
  // CHANGEイベントのときはセットする値を受け取る
  | { type: "CHANGE"; value: string }
  // NOTE: Invoke Actorの型推論はうまくやる方法が見つからなかったのでハードコードしています
  | {
    type: "xstate.done.actor.validate";
    // validateの返り値としてZodErrorまたはundefinedを受け取る
    output: ZodError | undefined;
  };

type Actors = {
  id: "validate";
  src: "validate";
  // 非同期関数のActorをlogicとする
  // [Output, Input]を渡している
  logic: PromiseActorLogic<
    ZodError<string> | undefined,
    { validator: ZodTypeAny; value: string }
  >;
};

type Actions =
  // setValueはCHANGEイベントのvalueを受け取る
  | { type: "setValue"; value: string }
  // setErrorはxstate.done.actor.validateイベントのoutputをerrorという引数で受け取る
  | {
    type: "setError";
    error: ZodError<string> | undefined;
  };

// マシンに渡す引数
// 初期値とvalidator(zodのスキーマ)、modeを指定して起動する
type Input = Partial<Pick<Context, "initialValue" | "validator" | "mode">>;

type Guards =
  | { type: "isAllMode" }
  | { type: "isOnBlurMode" }
  | { type: "isOnChangeMode" }
  | { type: "isOnTouchedMode" }
  | { type: "isDirty" }
  | { type: "isPristine" }
  | { type: "isValid" }
  | { type: "isInvalid" };

inputMachineの実装

生成されたコードにあるcreateMachineに必要な値をセットする

  1. 第一引数のcontextプロパティの定義
  2. 第二引数のactions, actors, guards, (delays)プロパティの実装
  3. 第一引数のstatesプロパティの解説

の順に説明する

Context


const inputMachine = createMachine({
  ...
  // Inputを受け取ってcontextの初期値を定義する
  context: ({ input }) => {
    const initialValue = input.initialValue ?? "";
    const validator = input.validator ?? z.string();
    const allowEmpty = getAllowEmpty(validator);

    return {
      initialValue,
      value: initialValue,
      required: !allowEmpty,
      validator,
      error: undefined,
      mode: input.mode ?? "all",
    };
  },
  ...
},{
  ...
})
getAllowEmptyの実装

zodのスキーマから必須かどうか判別する関数

/**
 * ZodStringまたはZodEffects(refine, superRefine)のみをサポート
 * min(0)があるかどうかでallowEmptyを判定する
 */
const getAllowEmpty = (schema: ZodTypeAny): boolean => {
  if (schema instanceof ZodEffects) {
    return getAllowEmpty(schema._def.schema);
  }
  if (schema instanceof ZodString) {
    return schema._def.checks.some(
      (check) => check.kind === "min" && check.value === 0,
    );
  }
  throw new Error("Invalid schema");
};

Actions

import { assign } from 'xstate'

const inputMachine = createMachine(
{
  ...
},
{
  actions: {
    // assign関数を使うとcontextの更新ができる
    setValue: assign(({ event }) => {
       // eventの型を絞ってあげることでvalueにアクセスできる
      if (event.type !== "CHANGE") return {};
      return {
        value: event.value,
      };
    }),
    setError: assign(({ event }) => {
      if (event.type !== "xstate.done.actor.validate") return {};
      return {
        error: event.output,
      };
    }),
  },
})

Actors

import { fromPromise } from 'xstate'

const inputMachine = createMachine(
{
  ...
},
{
  actors: {
    // fromPromiseで非同期関数を囲むことで型が効くようになる
    validate: fromPromise(async ({ input }) => {
      const result = await input.validator.safeParseAsync(input.value);
      return result.success ? undefined : result.error;
    }),
  },
})

Guards

// 相互にイベントレスで遷移するため片方の条件式を書いて、もう一方はその否定にしないと無限ループが起きてしまうことがあるので注意
const isDirty = ({ context }: { context: Context }) =>
  context.initialValue !== context.value;
const isPristine = ({ context }: { context: Context }) => !isDirty({ context });

const isValid = ({ context }: { context: Context }) => {
  if (context.required && !context.value) return false;
  return !context.error;
};
const isInvalid = ({ context }: { context: Context }) => !isValid({ context });

const inputMachine = createMachine(
{
  ...
},
{
  guards: {
    isDirty,
    isPristine,
    isValid,
    isInvalid,
    // contextの内容によってbooleanを返す
    isAllMode: ({ context }) => context.mode === "all",
    isOnBlurMode: ({ context }) => context.mode === "onBlur",
    isOnChangeMode: ({ context }) => context.mode === "onChange",
    isOnTouchedMode: ({ context }) => context.mode === "onTouched",
  },
})

States

const inputMachine = createMachine(
{
  // 同じ階層にあるstatesがParallel Stateであることを示す
  type: "parallel",
  states: {
    ...
    touchState: {
      // Initial Stateはuntuchedである
      initial: "untouched",
      states: {
        untouched: {
          // onで受け取ることのできるイベントを定義する
          on: {
            // FOCUSイベントを受け取ったらtargetのtouchedに状態遷移する
            FOCUS: {
              target: "touched",
            },
          },
        },
        touched: {
          // touchedはFinal Stateである
          type: "final",
        },
      },
    },
    validity: {
      initial: "invalid",
      states: {
        invalid: {
          // alwaysによってイベントレスな状態遷移を定義する
          always: {
            target: "valid",
            // isValidがtrueのときtargetのvalidへ遷移する
            guard: "isValid",
          },
        },
        valid: {
          always: {
            target: "invalid",
            guard: "isInvalid",
          },
        },
      },
    },
    editableState: {
      initial: "enabled",
      states: {
        // enabledはChild Stateを持つためstatesを持つ
        enabled: {
          states: {
            focusState: {
              initial: "unfocused",
              states: {
                unfocused: {
                  on: {
                    FOCUS: {
                      target: "focused",
                    },
                  },
                },
                focused: {
                  on: {
                    BLUR: {
                      target: "unfocused",
                    },
                    CHANGE: {
                      actions: {
                        type: "setValue",
                      },
                    },
                  },
                },
              },
            },
          },
          on: {
            DISABLE: {
              target: "disabled",
            },
          },
          type: "parallel",
        },
        disabled: {
          on: {
            ENABLE: {
              target: "enabled",
            },
          },
        },
      },
    },

  }
},
{
  ...
})
完成後のinputMachineの全体
import { PromiseActorLogic, assign, createMachine, fromPromise } from "xstate";
import { ZodEffects, ZodError, ZodString, ZodTypeAny, z } from "zod";

type Context = {
  initialValue: string;
  value: string;
  required: boolean;
  validator: ZodTypeAny;
  error: ZodError | undefined;
  mode: "onBlur" | "onChange" | "onTouched" | "all";
};

type Events =
  | { type: "FOCUS" }
  | { type: "ENABLE" }
  | { type: "DISABLE" }
  | { type: "BLUR" }
  | { type: "CHANGE"; value: string }
  | {
    type: "xstate.done.actor.validate";
    output: ZodError<string> | undefined;
  };

type Actors = {
  id: "validate";
  src: "validate";
  logic: PromiseActorLogic<
    ZodError | undefined,
    { validator: ZodTypeAny; value: string }
  >;
};

type Actions =
  | { type: "setValue"; value: string }
  | {
    type: "setError";
    error: ZodError | undefined;
  };

type Input = Partial<Pick<Context, "initialValue" | "validator" | "mode">>;

type Guards =
  | { type: "isAllMode" }
  | { type: "isOnBlurMode" }
  | { type: "isOnChangeMode" }
  | { type: "isOnTouchedMode" }
  | { type: "isDirty" }
  | { type: "isPristine" }
  | { type: "isValid" }
  | { type: "isInvalid" };

const isDirty = ({ context }: { context: Context }) =>
  context.initialValue !== context.value;
const isPristine = ({ context }: { context: Context }) => !isDirty({ context });
const isValid = ({ context }: { context: Context }) => {
  if (context.required && !context.value) return false;
  return !context.error;
};
const isInvalid = ({ context }: { context: Context }) => !isValid({ context });

/**
 * ZodStringまたはZodEffects(refine, superRefine)のみをサポート
 * min(0)があるかどうかでallowEmptyを判定する
 */
const getAllowEmpty = (schema: ZodTypeAny): boolean => {
  if (schema instanceof ZodEffects) {
    return getAllowEmpty(schema._def.schema);
  }
  if (schema instanceof ZodString) {
    return schema._def.checks.some(
      (check) => check.kind === "min" && check.value === 0,
    );
  }
  throw new Error("Invalid schema");
};

export const inputMachine = createMachine(
  {
    id: "inputMachine",
    type: "parallel",
    types: {
      actions: {} as Actions,
      events: {} as Events,
      context: {} as Context,
      input: {} as Input,
      actors: {} as Actors,
      guards: {} as Guards,
    },
    context: ({ input }) => {
      const initialValue = input.initialValue ?? "";
      const validator = input.validator ?? z.string();
      const allowEmpty = getAllowEmpty(validator);

      return {
        initialValue,
        value: initialValue,
        required: !allowEmpty,
        validator,
        error: undefined,
        mode: input.mode ?? "all",
      };
    },
    states: {
      touchState: {
        initial: "untouched",
        states: {
          untouched: {
            on: {
              FOCUS: {
                target: "touched",
              },
            },
          },
          touched: {
            type: "final",
          },
        },
      },
      dirtyState: {
        initial: "pristine",
        states: {
          pristine: {
            always: {
              target: "dirty",
              guard: "isDirty",
            },
          },
          dirty: {
            always: {
              target: "pristine",
              guard: "isPristine",
            },
          },
        },
      },
      validity: {
        initial: "invalid",
        states: {
          invalid: {
            always: {
              target: "valid",
              guard: "isValid",
            },
          },
          valid: {
            always: {
              target: "invalid",
              guard: "isInvalid",
            },
          },
        },
      },
      validationState: {
        initial: "idle",
        states: {
          idle: {
            always: {
              target: "validating",
              guard: "isAllMode",
            },
            on: {
              CHANGE: [
                {
                  guard: "isOnChangeMode",
                  target: "validating",
                },
              ],
              BLUR: [
                {
                  guard: "isOnBlurMode",
                  target: "validating",
                },
                {
                  guard: "isOnTouchedMode",
                  target: "validating",
                },
              ],
            },
          },
          validating: {
            on: {
              CHANGE: [
                {
                  guard: "isAllMode",
                  target: "validating",
                },
                {
                  guard: "isOnChangeMode",
                  target: "validating",
                },
                {
                  guard: "isOnTouchedMode",
                  target: "validating",
                },
              ],
              BLUR: [
                {
                  guard: "isAllMode",
                  target: "validating",
                },
                {
                  guard: "isOnBlurMode",
                  target: "validating",
                },
              ],
            },
            invoke: {
              id: "validate",
              src: "validate",
              input: ({ context }) => {
                return {
                  value: context.value,
                  validator: context.validator,
                };
              },
              onDone: [
                {
                  target: "validated",
                  actions: {
                    type: "setError",
                  },
                },
              ],
            },
          },
          validated: {
            on: {
              CHANGE: [
                {
                  guard: "isAllMode",
                  target: "validating",
                },
                {
                  guard: "isOnChangeMode",
                  target: "validating",
                },
                {
                  guard: "isOnTouchedMode",
                  target: "validating",
                },
              ],
              BLUR: [
                {
                  guard: "isAllMode",
                  target: "validating",
                },
                {
                  guard: "isOnBlurMode",
                  target: "validating",
                },
              ],
            },
          },
        },
      },
      editableState: {
        initial: "enabled",
        states: {
          enabled: {
            states: {
              focusState: {
                initial: "unfocused",
                states: {
                  unfocused: {
                    on: {
                      FOCUS: {
                        target: "focused",
                      },
                    },
                  },
                  focused: {
                    on: {
                      BLUR: {
                        target: "unfocused",
                      },
                      CHANGE: {
                        actions: {
                          type: "setValue",
                        },
                      },
                    },
                  },
                },
              },
            },
            on: {
              DISABLE: {
                target: "disabled",
              },
            },
            type: "parallel",
          },
          disabled: {
            on: {
              ENABLE: {
                target: "enabled",
              },
            },
          },
        },
      },
    },
  },
  {
    actions: {
      setValue: assign(({ event }) => {
        if (event.type !== "CHANGE") return {};
        return {
          value: event.value,
        };
      }),
      setError: assign(({ event }) => {
        if (event.type !== "xstate.done.actor.validate") return {};
        return {
          error: event.output,
        };
      }),
    },
    actors: {
      validate: fromPromise(async ({ input }) => {
        const result = await input.validator.safeParseAsync(input.value);
        return result.success ? undefined : result.error;
      }),
    },
    guards: {
      isDirty,
      isPristine,
      isValid,
      isInvalid,
      isAllMode: ({ context }) => context.mode === "all",
      isOnBlurMode: ({ context }) => context.mode === "onBlur",
      isOnChangeMode: ({ context }) => context.mode === "onChange",
      isOnTouchedMode: ({ context }) => context.mode === "onTouched",
    },
  },
);

inputMachineを扱うhooksを作成する

import { ChangeEventHandler, useCallback } from "react";
import { ActorRefFrom } from "xstate";
import { useSelector } from "@xstate/react";
import { inputMachine } from "./input.machine";

type Props = {
  // inputMachineのActorの参照を受け取る(Actorの参照の作成は後で解説)
  inputMachineRef: ActorRefFrom<typeof inputMachine>;
};

export const useInputMachine = ({ inputMachineRef }: Props) => {
  // useSelectorを使用すると参照のSnapshotの更新を購読できる
  const state = useSelector(inputMachineRef, (state) => state.value);
  const context = useSelector(inputMachineRef, (state) => state.context);

  const errorMessage = useSelector(inputMachineRef, (state) => {
    // エラーメッセージを整形
    const message = state.context.error?.issues
      .map((issue) => issue.message)
      .join("\n");

   
    // 現在のvalueがバリデーション済みでエラーがあるときにエラーを返す
    return state.matches({
      validity: "invalid",
      validationState: "validated",
    })
      ? message
      : null;
  });

  const isDirty = useSelector(inputMachineRef, (state) =>
    // state.matchesで状態の組み合わせが引数の通りか検証できる
    state.matches({ dirtyState: "dirty" }),
  );
  const isDisabled = useSelector(inputMachineRef, (state) =>
    state.matches({ editableState: "disabled" }),
  );
  const isFocusing = useSelector(inputMachineRef, (state) =>
    // ネストも可能
    state.matches({
      editableState: {
        enabled: {
          focusState: "focused",
        },
      },
    }),
  );
  const isTouched = useSelector(inputMachineRef, (state) =>
    state.matches({
      touchState: "touched",
    }),
  );
  const isValidating = useSelector(inputMachineRef, (state) =>
    state.matches({
      validationState: "validating",
    }),
  );
  const isValidated = useSelector(inputMachineRef, (state) =>
    state.matches({
      validationState: "validated",
    }),
  );

  const isValid = useSelector(inputMachineRef, (state) =>
    state.matches({
      validity: "valid",
    }),
  );

  // sendを使用してイベントをトリガできる
  const onChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
    (e) => inputMachineRef.send({ type: "CHANGE", value: e.target.value }),
    [inputMachineRef],
  );

  const onBlur = useCallback(
    () => inputMachineRef.send({ type: "BLUR" }),
    [inputMachineRef],
  );

  const onFocus = useCallback(
    () => inputMachineRef.send({ type: "FOCUS" }),
    [inputMachineRef],
  );

  const toggleEditableState = useCallback(() => {
    if (isDisabled) {
      inputMachineRef.send({ type: "ENABLE" });
    } else {
      inputMachineRef.send({ type: "DISABLE" });
    }
  }, [inputMachineRef, isDisabled]);

  return {
    state,
    context,
    selectors: {
      errorMessage,
      isDirty,
      isDisabled,
      isFocusing,
      isTouched,
      isValidating,
      isValidated,
      isValid,
    },
    handlers: {
      onChange,
      onBlur,
      onFocus,
    },
    toggleEditableState,
  };
};

上記のuseInputMachineと組み合わせて下記のようなコンポーネントで描画できます

import { ComponentProps } from "react";

type Props = Pick<
  ComponentProps<"input">,
  "onChange" | "onBlur" | "onFocus" | "id" | "type" | "value"
> & {
  isDisabled: boolean;
};

export const XStateInput = ({ isDisabled, ...props }: Props) => {
  return <input {...props} disabled={isDisabled} />;
};

実際にフォームを動かしてみる

Actorの参照を取りまとめるためだけのマシンを作成します

import { ActorLogicFrom, ActorRefFrom, createMachine } from "xstate";
import { inputMachine } from "../_components/input/input.machine";
import { z } from "zod";

type Types = {
  // fieldsMachineの子マシンとしてinputMachineを使用します
  actors: {
    src: "inputMachine";
    logic: ActorLogicFrom<typeof inputMachine>;
  };

  // contextにActorの参照を持ちます
  context: {
    emailInputRef: ActorRefFrom<typeof inputMachine>;
    passwordInputRef: ActorRefFrom<typeof inputMachine>;
    nicknameInputRef: ActorRefFrom<typeof inputMachine>;
  };

  guards: { type: "isValid" } | { type: "isInvalid" };
};

// 全てのフィールドがvalidなときにfieldsMachineはvalidになるという想定です
const isValid = ({ context }: { context: Types["context"] }) => {
  return (
    context.emailInputRef.getSnapshot().matches({ validity: "valid" }) &&
    context.passwordInputRef.getSnapshot().matches({ validity: "valid" }) &&
    context.nicknameInputRef.getSnapshot().matches({ validity: "valid" })
  );
};
const isInvalid = ({ context }: { context: Types["context"] }) =>
  !isValid({ context });

// 非同期なバリデーション用のテストコード
const checkNicknameDuplicate = async (nickname: string) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      if (nickname === "テスト") {
        resolve(false);
      } else {
        resolve(true);
      }
    }, 1000);
  });
};

export const fieldsMachine = createMachine(
  {
    id: "fieldsMachine",
    types: {} as Types,
    // spanwを使用してActorを起動します
    context: ({ spawn }) => ({
      emailInputRef: spawn("inputMachine", {
        id: "email",
        input: {
          validator: z.string().email(),
          mode: "onBlur",
        },
        // syncSnapshotは子のsnapshotが更新されたときに親のステートマシンでも同じタイミングでsnapshotの更新を実施するかどうか指定できます
        // isValidで子マシンのsnapshotを参照するためtrueにして同期します
        syncSnapshot: true,
      }),
      passwordInputRef: spawn("inputMachine", {
        id: "password",
        input: {
          validator: z.string().min(8).max(20),
          mode: "onChange",
        },
        syncSnapshot: true,
      }),
      nicknameInputRef: spawn("inputMachine", {
        id: "nickname",
        input: {
          initialValue: "テストユーザー",
          validator: z
            .string()
            .min(0)
            .max(20)
            .refine(
              checkNicknameDuplicate,
              "既に使用されているニックネームです",
            ),
          mode: "onTouched",
        },
        syncSnapshot: true,
      }),
    }),
    initial: "invalid",
    states: {
      invalid: {
        always: {
          target: "valid",
          guard: "isValid",
        },
      },
      valid: {
        always: {
          target: "invalid",
          guard: "isInvalid",
        },
      },
    },
  },
  {
    actors: {
      inputMachine,
    },
    guards: {
      isValid,
      isInvalid,
    },
  },
);

作成したサンプル

inputMachineの状態とコンテキスト、それらから計算した値を表示するサンプルをGitHubに上げていますので手元で試してみてください

Sample - State
Sample - Context
Sample - Variables

GitHub Pages - xstate-input-sample
GitHub - xstate-input-sample
Stately - inputMachine

おわりに

最近ハマっているXStateの紹介記事でしたがいかがだったでしょうか
想定よりボリュームが大きくなってしまったので読みにくいかも...と思いつつもこれくらいしっかり書かないと理解が難しいライブラリなので読んでいただける方がいたら幸いです!

個人的に下記の点が魅力的なライブラリなのでもう少し流行ってくれたら嬉しいと感じています

  • 状態遷移図がドキュメントとなる
  • グローバルステートを管理するライブラリとして可能性を感じている
    "4.0.0-beta.10 - コードベースのどこからでもアクセスできることがないので安全
    • 業務フローに必要な適切なcontextを定義できる
  • ロジックがステートマシン内に定義できるのでReactなどをUIの描画だけに責務を分けられる

コードの修正点はコメントに、感想はXのPostでお待ちしています!

X - @susiyaki_dev
X - Post

Discussion