Предисловие
Вопреки мнению, которое я видел в комментах к статьям о конечных автоматах, я не считаю, что их применение это какой-то «спагетти». Наоборот, они позволяют реализовать довольно сложную 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 [ссылка удалена модератором]
ссылка на оригинал статьи https://habr.com/ru/post/675672/
Добавить комментарий