Предисловие
Вопреки мнению, которое я видел в комментах к статьям о конечных автоматах, я не считаю, что их применение это какой-то "спагетти". Наоборот, они позволяют реализовать довольно сложную state transition логику.
Хотя автомат из 100500 стейтов с вложенными и параллельным стейтами, пожалуй, будет слабо читаемым. Но это уже тема для Separation of Concerns - разделения ответственности.
В этой статье я хочу рассмотреть замечательный инструмент XState.
Который позволяет как описывать эти автоматы, так и рисовать в редакторе и визуализировать.
XState
Первый раз с XState я столкнулся, используя эту библиотеку.
Но в тот раз знакомство как то не задалось, как говорится, не зашло
Второй раз — в цикле статей Khalil Stemmler, его предлагалось использовать в слое Interaction logic.
И в этот раз я решил разобраться в теме, тем более искал тему для вебинара.
Для начала немного теории из дискретной математики (признаюсь, прогуливал лекции в универе, хз как сдал...)
Коне́чный автома́т (КА) в теории алгоритмов — математическая абстракция, модель дискретного устройства, имеющего один вход, один выход и в каждый момент времени находящегося в одном состоянии из множества возможных. Является частным случаем абстрактного дискретного автомата, число возможных внутренних состояний которого конечно.
Собственно, XState и реализует этот самый Конечный автомат или, если на латыни - Finite State Machine
Это сразу показалось мне хорошей темой для реализации логики форм и способом уйти от кучи useState и useEffect...
Но лучше всего теорию разбирать на практике.
Практика
Разберем простой пример — форма авторизации.
Казалось бы, что там делать? 2 инпута, кнопка, дернули API, авторизовали - PROFIT!
Но давайте сначала сделаем небольшую аналитику фичи:
Форма входа должна обрабатывать ошибки ввода
Форма входа не должна показывать ошибки пока поле не заполнено
Ошибка поля должна пропадать после ввода данных
Поле должно валидироваться явно (с показом ошибки) только после blur события
Не должно быть возможность отправить форму, пока есть ошибки валидации
Поля должны валидироваться не явно, для требования 5
После нажатия на кнопку отправки формы, кнопка должна исчезать и исключать повторную отправку формы
Ошибки сервера должны выводится в читаемом виде и не блокировать повторную отправку формы
После успешной авторизации форма не должна выводится, вместо этого должно показываться сообщение, что пользователь уже авторизован
Вот так. 9 требований для простой формы авторизации из 2 полей без всяких там 2FA. И если постараться, можно добавить и еще.
Ну, время писать код.
Подготовка
Не будем изобретать велосипед и воспользуемся Create React App и установим необходимые зависимости.
yarn create react-app xstate-demo --template typescript
cd xstate-demo
yarn install
yarn add xstate @xstate/react mobx mobx-react
Планируем архитектуру
Возьмем упрощенную feature sliced архитектуру.
В shared поместим общие компоненты: форм инпуты, форм машину, сторы, и типы
В features у нас будет одна фича — login. Суть фичей в том, что внешние зависимости, или другие фичи не тянут зависимости из нее. Таким образом мы можем просто удалить фичу, вызов ее root компонента и заменить на другую фичу, и приложение от этого не пострадает.
Пишем код
1. Проектируем машину
Мы сделаем универсальную машину, которую можно будет использовать и в других формах.
Определим состояния, события и actions, а так же типы
export enum States {
dataEntry = 'dataEntry',
awaitingResponse = 'awaitingResponse',
dataEntryError = 'dataEntryError',
serviceError = 'serviceError',
success = 'success',
}
export enum Events {
ENTER_DATA = 'ENTER_DATA',
BLUR_DATA = 'BLUR_DATA',
SUBMIT = 'SUBMIT',
}
export enum Actions {
setField = 'setField',
}
Определим тип данных, которые будем хранить в контексте машины
export interface FormFieldConfigValidationResult {
result: boolean,
errorMessage?: string
}
export interface FormFieldConfig {
required: boolean,
field: string,
validator?: (value: any) => FormFieldConfigValidationResult
}
export interface FormFieldErrors {
[field: string]: {
message: string
}
}
export type FormMachineContext = {
data: any,
dataEntryErrors: FormFieldErrors,
serviceErrors: any,
fields: FormFieldConfig[],
canSubmit: boolean,
}
2. Конфиг машины
Сделаем factory для создания машины с конфигом
export interface FormMachineFactoryParams {
fields: FormFieldConfig[],
onSubmit: (context: FormMachineContext) => Promise<any>,
onDone: (data: any) => any
}
export const formMachineFactory = ({
fields, onSubmit, onDone,
}: FormMachineFactoryParams) => {
return createMachine({
id: 'login',
initial: States.dataEntry,
context: {
data: {},
dataEntryErrors: {} as FormErrors,
serviceErrors: {} as FormErrors,
fields,
canSubmit: false,
} as FormMachineContext,
states: {},
actions: {},
guards: {}
})
}
Пропишем первые transitions — обработчики на события. Суть такая — когда мы находимся в стадии dataEntry, то обрабатываем только эти события ввода данных, потерю фокуса и отправки формы.
...
states: {
[States.dataEntry]: {
on: {
[Events.ENTER_DATA]: {
actions: Actions.setField,
},
// при потере фокуса валидируем поле и если isInvalid переключаем
// машину в dataEntryError
[Events.BLUR_DATA]: [
{cond: 'isInvalid', target: States.dataEntryError},
],
[Events.SUBMIT]: {
cond: 'canSubmitGuard',
target: States.awaitingResponse
},
},
},
},
...
Далее пишем actions - эффекты в стиле "выстрелил и забыл" https://xstate.js.org/docs/guides/actions.html
В эффекте setField мы данные ввода из форм инпутов и валидируем всю форму, чтобы понять, когда разблокировать возможность отправки. А так же очищаем context.dataEntryErrors чтобы убрать вывод ошибки.
function canSubmit(context: FormMachineContext) {
if (Object.keys(context.fields).length === 0) {
return true;
}
return context.fields.reduce((acc, val) => {
const fieldValue = context.data[val.field];
const reqPredicate = val.required ? !!fieldValue : true;
return acc &&
(typeof val.validator === 'function' ?
val.validator(context.data[val.field]).result : true)
&& reqPredicate
}, true);
}
...
actions: {
[Actions.setField]: (context, event) => {
context.data[event.data.field] = event.data.value;
delete context.dataEntryErrors[event.data.field];
context.canSubmit = canSubmit(context);
},
},
...
Guards — условия для переходов в стадии. Их мы используем, чтобы явно валидировать поле и переходить в случае ошибки в стейт dataEntryError. https://xstate.js.org/docs/guides/guards.html#guards-condition-functions
...
guards: {
isInvalid: (context, event) => {
if (!event.data.value) {
return false;
}
const field = context.fields.find(f => f.field === event.data.field);
if (!field) {
return false;
}
const res: FormFieldConfigValidationResult = field.validator ? field.validator(event.data.value) : {
result: true
};
if (!res.result) {
context.dataEntryErrors[event.data.field] = {
message: res.errorMessage,
};
}
return !res.result
},
canSubmitGuard: (context) => {
return context.canSubmit;
}
}
...
Дальше опишем остальные стадии. В стейте awaitingResponse будем вызывать service - асинхронный эффект. Он может быть Promise, Callback, Observables и другой машиной. https://xstate.js.org/docs/guides/communication.html#the-invoke-property
В нашем случае — это Promise.
states: {
...
[States.awaitingResponse]: {
id: 'submit',
//это сервис - асинхронный эффект
invoke: {
src: (context) => {
return onSubmit(context);
},
onDone: {
target: States.success
},
onError: [
{
actions: (context, event) => {
context.serviceErrors[event.type] = event.data;
},
target: States.serviceError
}
]
}
},
[States.dataEntryError]: {
on: {
[Events.ENTER_DATA]: {
// При вводе данных при ошибке нам нужно как установить данные, так и переключить state
actions: Actions.setField,
target: States.dataEntry
},
}
},
[States.serviceError]: {
on: {
[Events.SUBMIT]: {
target: States.awaitingResponse
},
[Events.ENTER_DATA]: {
// При вводе данных при ошибке нам нужно как установить данные, так и переключить state
actions: Actions.setField,
target: States.dataEntry
},
}
},
[States.success]: {
type: 'final',
onDone: {
actions: onDone
},
},
...
Таким образом мы получили читаемую, тестируемую, защищенную form logic. Которую при этом можем переиспользователь на любой платформе, например в Vue или Svelte, или вообще в ваниле.
Крутым бонусом будет возможность посмотреть интерактивные стейтчарты через инструмент Visualizer или писать машины через визуальный редактор
3. Пишем store и компоненты
Здесь мы делаем простой стор и эмулируем запрос к Api. Первый запрос всегда будет ошибочным.
import {makeAutoObservable} from "mobx";
import {User} from "../types/User";
class AuthStore {
user?: User
error: boolean = true;
constructor() {
makeAutoObservable(this);
}
async auth(login, password) {
this.user = await (new Promise<User>((resolve, reject) => {
if (this.error) {
this.error = false;
reject('Hello error!');
}
setTimeout(() => {
resolve({
role: 'admin',
login: 'admin'
})
}, 2000);
}))
}
}
export const authStore = new AuthStore();
Напишем композицию стандартного инпута, чтобы выводить ошибки
import React, {InputHTMLAttributes} from "react";
import './FormInput.css'
export interface FormInputParams extends InputHTMLAttributes<HTMLInputElement> {
error?: string
}
export default function FormInput({error, ...rest}: FormInputParams) {
return (
<div className={"form-input"}>
<input
{...rest} />
{error && <div style={{color: 'red'}}>{error}</div>}
</div>
)
}
Общий хук для всех form machine. Здесь будут функции обработчики, которые будем вешать на поля ввода, сама машина и стейт и получение ошибок
import {useMemo} from "react";
import {Events, formMachineFactory, FormMachineFactoryParams} from "./form-machine";
import {useMachine} from "@xstate/react";
export default function useFormMachine(formConfig: FormMachineFactoryParams) {
// При перерендере машина должна сохранятся
const machine = useMemo(() => formMachineFactory(formConfig), []);
const [state, send] = useMachine(machine);
function getError(field: string) {
const error = state.context.dataEntryErrors[field];
return error && (error.message || `${field} error`);
}
function sendEvent(config: { field: string, type: Events, event: any }) {
const {type, event, field} = config;
send({
type,
data: {
value: event.target.value,
field,
}
});
}
function onBlur(event:any, field) {
sendEvent({
type: Events.BLUR_DATA,
event,
field,
});
}
function onChange(event:any, field:string) {
sendEvent({
type: Events.ENTER_DATA,
event,
field,
});
}
return {
machine,
state,
send,
sendEvent,
onBlur,
onChange,
getError,
}
}
Хук для login machine. Тут будем хранить конфиг полей и создавать машину.
import {
FormFieldConfigValidationResult,
FormMachineFactoryParams
} from "../../shared/forms/form-machine";
import {authStore} from "../../shared/stores/Auth";
import useFormMachine from "../../shared/forms/useFormMachine";
export default function useLoginMachine() {
const formConfig: FormMachineFactoryParams = {
fields: [
{
field: 'email',
required: true,
validator: (value: string): FormFieldConfigValidationResult => {
if (!value) {
return {
result: false,
errorMessage: 'Please Enter Email'
}
}
let regexEmail = /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/;
return {
result: !!value.match(regexEmail),
errorMessage: 'Email wrong format'
}
}
},
{
field: 'password',
required: true,
validator: (value: string) => {
return {
result: value?.length >= 4,
errorMessage: 'Password length is less then 4'
}
}
},
],
onSubmit: (context) => authStore.auth(context.data.email, context.data.password),
onDone: () => {
console.log('Auth!');
}
}
return {
...useFormMachine(formConfig)
}
}
Теперь компонент LoginForm. Форму выводим только если пользователь не авторизован. Так же для наглядности выведем содержимое контекста машины.
import React, {useMemo} from 'react'
import {useMachine} from '@xstate/react';
import {
Events,
FormFieldConfigValidationResult,
formMachineFactory,
FormMachineFactoryParams,
States
} from "../../shared/forms/form-machine";
import FormInput from "../../shared/forms/FormInput";
import {authStore} from "../../shared/stores/Auth";
import {observer} from "mobx-react";
import useLoginMachine from "./useLoginMachine";
export default observer(() => {
const {state, send, onBlur, onChange, getError} = useLoginMachine();
return (<div>
{state.matches(States.success) ?
<>
User logged in
<pre>{JSON.stringify(authStore.user, null, "\t")}</pre>
</>
: <>
<div style={{textAlign: "center"}}>
<div style={{marginBottom: "1rem"}}>
Current State is <b>{state.value.toString()}</b>
</div>
<FormInput
onBlur={(e) => onBlur(e, 'email')}
onChange={(e) => onChange(e, 'email')}
error={getError('email')}
/>
<FormInput
onBlur={(e) => onBlur(e, 'password')}
onChange={(e) => onChange(e, 'password')}
error={getError('password')}
/>
{state.matches(States.awaitingResponse) ? 'Loading...' :
<button disabled={!state.context.canSubmit && !state.matches(States.serviceError)}
onClick={() => send(Events.SUBMIT)}>Login</button>}
{state.matches(States.serviceError) && <div style={{color: 'red'}}>
Service error: {state.context.serviceErrors['error.platform']}. Try again.</div>}
</div>
<pre>{JSON.stringify(state.context, null, 4)}</pre>
</>
}
</div>
)
})
4. Тут можно пощупать тестовое приложение
https://codesandbox.io/s/sparkling-cloud-zzpxhp?from-embed или стянуть с гитхабчика
Первая отправка выдаст ошибку, вторая success
5. Проверка требований
[x] Форма входа должна обрабатывать ошибки ввода
[x] Форма входа не должна показывать ошибки, пока поле не заполнено
[x] Ошибка поля должна пропадать после ввода данных
[x] Поле должно валидироваться явно (с показом ошибки) только после blur события
[x] Не должно быть возможность отправить форму, пока есть ошибки валидации
[x] Поля должны валидироваться не явно при вводе, для требования 5
[x] После нажатия на кнопку отправки формы, кнопка должна исчезать и исключать повторную отправку формы
[x] Ошибки сервера должны выводится в читаемом виде и не блокировать повторную отправку формы
[x] После успешной авторизации форма не должна выводится, вместо этого должно показываться сообщение, что пользователь уже авторизован
Как мы видим, все требования выполняются — PROFIT!
Вывод
Мы видим, что XState сильно упрощает работу над interaction logic, улучшает архитектуру приложения, читаемость и тестируемость кода.
В следующей статье продолжим практику с XState и поработаем с delayed events and transitions, parallel и hierarchical state nodes, а так же коснемся вопросов тестирования.
О себе
CTO в HR-tech стартапе Huntica [ссылка удалена модератором]
Комментарии (5)
GlebYP
09.07.2022 18:55Классное решение!
Инспектор и редактор состояний достаточно хорошо визуализируют и упрощают.
Раздумываю взять этот API как стандарт описания логики FSM
nin-jin
Ох уж это искусство всё усложнять.. Вот, смотрите, никаких машин состояний, только чистая незамутнённая реактивность на 50 строк вместо 200:
А вот использующая эту модель форма на том же Реакте на 50 строк вместо 100:
nin-jin
Ну а на view.tree эта форма вообще в 30 строк укладывается:
GlebYP
Использование try catch разве не просаживает производительность?
(откуда-то с с детства есть представление что использовать их можно только в крайних случаях когда иначе ну совсем никак)nin-jin
Уже давно как не просаживает.