Предисловие

Вопреки мнению, которое я видел в комментах к статьям о конечных автоматах, я не считаю, что их применение это какой-то "спагетти". Наоборот, они позволяют реализовать довольно сложную state transition логику.

Хотя автомат из 100500 стейтов с вложенными и параллельным стейтами, пожалуй, будет слабо читаемым. Но это уже тема для Separation of Concerns - разделения ответственности.

В этой статье я хочу рассмотреть замечательный инструмент XState.

Который позволяет как описывать эти автоматы, так и рисовать в редакторе и визуализировать.

XState

Первый раз с XState я столкнулся, используя эту библиотеку.

Но в тот раз знакомство как то не задалось, как говорится, не зашло

Второй раз — в цикле статей Khalil Stemmler, его предлагалось использовать в слое Interaction logic.

И в этот раз я решил разобраться в теме, тем более искал тему для вебинара.

Для начала немного теории из дискретной математики (признаюсь, прогуливал лекции в универе, хз как сдал...)

Коне́чный автома́т (КА) в теории алгоритмов — математическая абстракция, модель дискретного устройства, имеющего один вход, один выход и в каждый момент времени находящегося в одном состоянии из множества возможных. Является частным случаем абстрактного дискретного автомата, число возможных внутренних состояний которого конечно.

Собственно, XState и реализует этот самый Конечный автомат или, если на латыни - Finite State Machine

Это сразу показалось мне хорошей темой для реализации логики форм и способом уйти от кучи useState и useEffect...

Но лучше всего теорию разбирать на практике.

Практика

Разберем простой пример — форма авторизации.

Казалось бы, что там делать? 2 инпута, кнопка, дернули API, авторизовали - PROFIT!

Но давайте сначала сделаем небольшую аналитику фичи:

  1. Форма входа должна обрабатывать ошибки ввода

  2. Форма входа не должна показывать ошибки пока поле не заполнено

  3. Ошибка поля должна пропадать после ввода данных

  4. Поле должно валидироваться явно (с показом ошибки) только после blur события

  5. Не должно быть возможность отправить форму, пока есть ошибки валидации

  6. Поля должны валидироваться не явно, для требования 5

  7. После нажатия на кнопку отправки формы, кнопка должна исчезать и исключать повторную отправку формы

  8. Ошибки сервера должны выводится в читаемом виде и не блокировать повторную отправку формы

  9. После успешной авторизации форма не должна выводится, вместо этого должно показываться сообщение, что пользователь уже авторизован

Вот так. 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, а так же коснемся вопросов тестирования.

О себе

Комментарии (5)


  1. nin-jin
    08.07.2022 09:56

    Ох уж это искусство всё усложнять.. Вот, смотрите, никаких машин состояний, только чистая незамутнённая реактивность на 50 строк вместо 200:

    export class AuthModel extends Object {
      
      @mem api() { return new API() }
    
      @mem token( next = "" ) { return next }
    
      @mem login( next = "" ) { return next }
      @mem password( next = "" ) { return next }
    
      @mem loginCheck() {
        
        if( !this.login() )
          throw new Error( "Required" )
    
        if( !this.api().IsUserExists( this.login() ) )
          throw new Error("Doesn't exists");
    
      }
    
      @mem passwordCheck() {
        if( this.password().length < 4 )
          throw new Error( "At least 4 letter" )
      }
    
      @act signIn() {
        
        try {
        
          // Guards
          this.loginCheck()
          this.passwordCheck()
    
        } catch( error ) {
          
          // Suspense
          if( error instanceof Promise ) throw error
          throw new Error( "Form isn't filled correctly" )
    
        }
    
        const token = this.api().getAuthToken(
          this.login(),
          this.password(),
        )
    
        this.token( token )
    
      }
    
      @act signOut() {
        this.token( "" )
      }
    
    }

    А вот использующая эту модель форма на том же Реакте на 50 строк вместо 100:

    export class AuthForm extends Component<AuthForm> {
      
      @mem auth() { return new AuthModel() }
    
      submit( event: FormEvent<HTMLFormElement> ) {
        event.preventDefault()
      }
    
      compose() {
        if (this.auth().token()) {
          
          return (
            <div
              id={this.id}
              className="authForm"
              >
          
              <div
                id={`${this.id}-message`}
                className="authForm-message"
                >
                Signed in
              </div>
              
              <Button
                id={`${this.id}-signOut`}
                action={() => this.auth().signOut()}
                title={() => "Sign out"}
              />
              
            </div>
          )
          
        } else {
        
          return (
            <form
              id={ this.id }
              className="authForm"
              onSubmit={ action(this).submit }
              >
            
              <FieldString
                id={ `${this.id}-login` }
                hint={ ()=> "Login" }
                value={ next => this.auth().login( next ) }
                check={ ()=> this.auth().loginCheck() }
              />
    
              <FieldString
                id={ `${this.id}-password` }
                hint={ ()=> "Password" }
                value={ next => this.auth().password( next ) }
                check={ ()=> this.auth().passwordCheck() }
              />
    
              <Button
                id={ `${this.id}-signIn` }
                action={ ()=> this.auth().signIn() }
                title={ ()=> "Sign In" }
              />
              
            </form>
          )
          
        }
      }
    
    }


    1. nin-jin
      08.07.2022 10:30
      +1

      Ну а на view.tree эта форма вообще в 30 строк укладывается:

      export class $my_auth_form extends $.$my_auth_form {
          
          sub() {
              return [ this.auth().token() ? this.Signed_in() : this.Signed_out() ]
          }
          
      }


    1. GlebYP
      09.07.2022 18:57

      Использование try catch разве не просаживает производительность?

      (откуда-то с с детства есть представление что использовать их можно только в крайних случаях когда иначе ну совсем никак)


      1. nin-jin
        09.07.2022 19:56

        Уже давно как не просаживает.


  1. GlebYP
    09.07.2022 18:55

    Классное решение!
    Инспектор и редактор состояний достаточно хорошо визуализируют и упрощают.
    Раздумываю взять этот API как стандарт описания логики FSM