【XState】ステートマシンライブラリでreact-hook-formのバリデーターを作る
はじめに
この記事ではXStateというステートマシン(有限オートマトン)のライブラリの使い方を紹介します
現状XStateに関する日本語の記事はほとんどありません
国内での認知が低い理由として、
- 学習コストが高く導入が難しい
- 記事も少ないためインプットが難しい
- 実際にある程度使ってみないと慣れるのに時間がかかる
- 実務レベルのサンプルが公式にない(デファクトスタンダードis何?)
があります
僕自身もある程度感覚を掴み始めたレベルですので参考程度に読んでいただければと思います
また、Zennに本として公式ドキュメントを飜訳したりTipsなどを書いているものもあるので事前に目を通してからこの記事を読んでいただけると理解しやすいかと思います
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
のステートマシンの状態遷移図
では、完成したステートマシンの状態遷移図になります
(小さくて見にくいので後ほど拡大画像と一緒に細かく解説します)
なんとこのライブラリはコード↔状態遷移図を相互に変換することが可能です
上記の状態遷移図から生成されるコード
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である
dirtyState
- Initial Stateは
pristine
- AlwaysでContext内のフィールドの初期値(
initialValue
)と現在の値(value
)が異なれば遷移する
editableState
- Initial stateは
{enabled: "unfocused"}
-
DISABLE
/ENABLE
Eventで編集可能かどうかが相互に状態遷移する -
enabled
なときはChild Stateを持つ
enabledのとき
-
FOCUS
/BLUR
Eventでフォーカスしているかどうかが相互に状態遷移する -
focused
であるとき、CHANGE
Eventを実行してsetValue
ActionでContext内のvalue
を更新する
validity
- Initial Staetは
invalid
- AlwaysでContext内のエラー(
error
)が存在するかどうかで遷移する
validationState
-
mode
によってバリデーション実行タイミングが変わる(図参照) -
CHANGE
/BLUR
Eventをトリガーとしてvalidating
に状態遷移する-
validating
ではInvoke Actorとしてvalidate
が実行され、その実行結果をsetError
ActionでContextに反映している
-
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
に必要な値をセットする
- 第一引数の
context
プロパティの定義 - 第二引数の
actions
,actors
,guards
, (delays
)プロパティの実装 - 第一引数の
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に上げていますので手元で試してみてください
GitHub Pages - xstate-input-sample
GitHub - xstate-input-sample
Stately - inputMachine
おわりに
最近ハマっているXStateの紹介記事でしたがいかがだったでしょうか
想定よりボリュームが大きくなってしまったので読みにくいかも...と思いつつもこれくらいしっかり書かないと理解が難しいライブラリなので読んでいただける方がいたら幸いです!
個人的に下記の点が魅力的なライブラリなのでもう少し流行ってくれたら嬉しいと感じています
- 状態遷移図がドキュメントとなる
- グローバルステートを管理するライブラリとして可能性を感じている
"4.0.0-beta.10 - コードベースのどこからでもアクセスできることがないので安全- 業務フローに必要な適切なcontextを定義できる
- ロジックがステートマシン内に定義できるのでReactなどをUIの描画だけに責務を分けられる
コードの修正点はコメントに、感想はXのPostでお待ちしています!
Discussion