Привет, Хаброжители!
Вы когда-нибудь отлаживали такой компонент пользовательского интерфейса, где достаточно нажать в неверном порядке несколько кнопок – и приложение валится? А не доводилось ли вам ломать голову, пытаясь отследить, почему в некоторых случаях форма отправляется нормально, а в других — отказывает? Такие неприятные сценарии зачастую возникают по одной базовой причине: непредсказуемое управление состоянием. Мы привыкли полагаться на булевы флаги, рассеянные по коду инструкции if-else, тем временем надеясь, что наше приложение будет правильно работать, чего бы пользователь ни делал в интерфейсе.
Аккуратно справиться с таким хаосом помогают конечные автоматы. Они не позволяют вашему приложению скатиться в какие-то неопределённые состояния, а предоставляют формальную модель, в которой явно прописываются все возможные состояния, в которых может оказаться ваша система, а также в точности указывается, как организуются переходы между этими состояниями. Можете считать, что пишете контракт, в котором регулируется поведение вашего приложения. В этой статье разобрано, что представляют собой конечные автоматы, почему они важны и как научиться ими пользоваться, чтобы создавать более надёжные приложения.
Что такое конечный автомат?
В сущности, конечный автомат — это математическая модель вычислений, такая, которая в любой момент времени может существовать лишь одном из конечного множества состояний. Автомат может переходить из одного состояния в другое в ответ на поступающий в него определённый ввод или на конкретные события. На первый взгляд эти формулировки могут показаться абстрактными, но на самом деле мы повсюду сталкиваемся с конечными автоматами в повседневной жизни.
Рассмотрим светофор. У него три состояния: Красный, Жёлтый и Зелёный. В любой момент светофор находится в одном из этих состояний, причём, эти состояния меняются в предсказуемой последовательности. Светофор не может показывать одновременно красный и зелёный, а также (в большинстве моделей) не может перескочить непосредственно с красного на зелёный, не миновав предварительно жёлтый. В этом и заключается суть конечного автомата: чёткие состояния и явные переходы между ними.

Рис. 1: светофор: пример простейшего конечного автомата
В разработке ПО при помощи конечных автоматов удобно моделировать сложные поведения так, что они легко понятны, и при этом их невозможно использовать неправильно. Вместо того, чтобы управлять состоянием через разрозненные булевы переменные и логику условных операторов, будем явно определять все интересующие нас состояния и чётко описывать, как переходить между ними.
Анатомия конечного автомата
Каждый конечный автомат состоит из пяти фундаментальных компонентов, благодаря взаимодействию которых обеспечивается предсказуемое поведение.
Состояния — это дифференцированные наборы условий, в которых может существовать ваша система. Например, организованная в виде потока аутентификация пользователя допускает такие состояния как “Idle” (Ожидание), “Authenticating” (Аутентификация идёт), “Authenticated” (Аутентификация пройдена) и “Error” (Ошибка). Каждое состояние — это информативный мгновенный снимок, демонстрирующий, в каком состоянии находится ваше приложение на момент снимка.
События — это триггеры, вызывающие переходы от состояния к состоянию. Это входные данные, на которые реагирует машина, в частности, “SUBMIT_FORM”, “SUCCESS” или “ERROR.” События — единственный способ переходить между состояниями, они гарантируют, что все изменения являются предусмотренными и отслеживаемыми.
Переходы определяют, как следовать из состояния в состояние после того, как произойдёт конкретное событие. Смысл перехода может быть таков: «Когда в состоянии «идёт аутентификация» происходит событие SUCCESS — перейти в состояние «Аутентификация пройдена»». Эти состояния формируют поведение машины.
Исходным называется то состояние, из которого автомат начинает работу. Каждому конечному автомату требуется исходная точка, которая должна естественным образом соответствовать тому состоянию, с которого начинается ваш поток задач.
Бывают и конечные состояния, которые (опционально) указывают такие положения, в которых работа машины завершена. Не во всех конечных автоматах требуются конечные состояния — машина может работать и неопределённо долго как, например, двухпозиционный переключатель, который вечно переходит из одного положения в другое.

Рис. 2: Сравнение традиционного подхода и конечных автоматов
Рассмотрим сценарий с выборкой данных. Если вы работаете с традиционными булевыми флагами, то среди них могут быть такие как isLoading, isError и hasData. Но что будет, если как isLoading, так и isError оказываются true? Или когда hasData является true, и isLoading — также true? Такие невозможные состояния приводят к багам в UI и пограничным случаям, которые сложно тестировать и исправлять. Конечный автомат полностью исключает такие сценарии, так как они становятся невозможными на уровне структуры.
Кроме того, конечные автоматы обеспечивают самодокументирование кода. Если посмотреть на определение конечного автомата, то сразу же становятся понятны все состояния, в которых может находиться система, а также как система переходит от состояния к состоянию. Это бесценно при вводе новых коллег в курс дела, отладке проблем, возникающих в продакшене, а также при поддержке долгоживущих баз кода. Сама машина превращается в живую документацию, которая никогда не рассинхронизируется с реализацией.
Ещё одно серьёзное достоинство — тестируемость. Работая с конечным автоматом, можно перечислить все возможные состояния и переходы между ними, благодаря чему удобно писать полностью покрывающие их наборы тестов. Можно убедиться, что каждое событие инициирует правильный переход и таким образом защититься от недопустимых переходов, а также убедиться, что все действия выполняются вовремя.
Практический пример: отправка формы
Давайте напишем простой конечный автомат, обрабатывающий поток задач, связанных с отправкой формы — и увидим все эти шаги в действии. Сначала воспользуемся обычным JavaScript, чтобы понять основы и подготовиться к использованию каких-либо библиотек.
const STATES = { IDLE: 'idle', SUBMITTING: 'submitting', SUCCESS: 'success', ERROR: 'error' }; const EVENTS = { SUBMIT: 'SUBMIT', SUCCESS: 'SUCCESS', ERROR: 'ERROR', RETRY: 'RETRY' }; class FormStateMachine { constructor() { this.state = STATES.IDLE; } transition(event) { const currentState = this.state; if (currentState === STATES.IDLE && event === EVENTS.SUBMIT) { this.state = STATES.SUBMITTING; return true; } if (currentState === STATES.SUBMITTING && event === EVENTS.SUCCESS) { this.state = STATES.SUCCESS; return true; } if (currentState === STATES.SUBMITTING && event === EVENTS.ERROR) { this.state = STATES.ERROR; return true; } if (currentState === STATES.ERROR && event === EVENTS.RETRY) { this.state = STATES.SUBMITTING; return true; } console.warn(`Invalid transition: ${event} in state ${currentState}`); return false; } getState() { return this.state; } } const formMachine = new FormStateMachine(); console.log(formMachine.getState()); // => 'ожидание' formMachine.transition(EVENTS.SUBMIT); console.log(formMachine.getState()); // => 'отправка' formMachine.transition(EVENTS.SUCCESS); console.log(formMachine.getState()); // => 'успех'

Рис. 3: Конечный автомат для отправки форм
В этой простой реализации продемонстрирована суть концепции. Мы исключили всякую возможность неопределённого состояния. Варианты isSubmitting и isSuccess не могут одновременно быть true. Автомат всегда находится в строго одном состоянии, а переходы осуществляются только в результате явных событий.
Иерархические и параллельные состояния
По мере усложнения приложения приходится иметь дело со сценариями, в которых моделирование состояния становится всё изощрённее. Именно в таких ситуациях становятся особенно ценными иерархические и параллельные состояния.
Иерархические состояния (также называемые «вложенными») позволяют создавать подсостояния в рамках родительского состояния. Допустим, есть музыкальный плеер с состоянием «Playing» («Воспроизводится»). Внутри «Playing» могут быть такие подсостояния «Volume Adjusting» (Регулировка громкости) или «Seeking» («Поиск»). Эти подсостояния наследуют поведение от своего родительского состояния, обогащая его более специфической функциональностью.
const musicPlayerStates = { stopped: {}, playing: { initial: 'normal', states: { normal: {}, volumeAdjusting: {}, seeking: {} } }
Такая иерархия означает, что, будучи в состоянии «volumeAdjusting», вы также находитесь в состоянии «playing». Такие события как «PAUSE» (Пауза), применимые к состоянию «Воспроизведение», будут работать независимо от того, в каком подсостоянии вы сейчас находитесь. Так сокращается объём дублируемого кода, благодаря чему автомат становится удобнее поддерживать.
Параллельные состояния позволяют одновременно выполнять несколько состояний автомата. Представьте себе видеоплеер, в котором требуется независимо отслеживать одновременно как состояние воспроизведения (остановлено, воспроизводится, пауза), так и состояние загрузки (ожидание, загружается, загружено). Это непересекающиеся плоскости, которые в конечном автомате можно смоделировать как параллельные регионы.
Действия и побочные эффекты
Конечные автоматы блестяще помогают не только управлять тем, в каком именно состоянии вы находитесь, но и координировать побочные эффекты. Действия — это функции, выполняемые при переходах между состояниями. В функциях можно инициировать вызовы к API, обновлять объектную модель документа (DOM), диспетчеризовать события или реализовывать любые другие побочные эффекты.
В конечных автоматах бывает три типа действий:
Действия входа, выполняемые при входе в состояние. Например, когда мы входим в состояние «Submitting» («Отправка»), можно инициировать вызов к API.
Действия выхода, выполняемые при выходе из состояния. Их можно использовать для сброса ожидающих запросов или очистки временных данных.
Действия перехода выполняются в процессе конкретного перехода. Они полезны при логировании, аналитике или реализации условных побочных эффектов.
{ idle: { on: { SUBMIT: { target: 'submitting', actions: ['validateForm', 'logSubmission'] } } }, submitting: { entry: ['callAPI'], exit: ['clearPendingRequest'], on: { SUCCESS: { target: 'success', actions: ['showSuccessMessage'] } } } }
Такой декларативный подход к побочным эффектам позволяет с лёгкостью понять, что и когда происходит. Можно бегло просмотреть код — и увидеть, что вызов к API поступает при переходе в состояние отправки, а не оказывается погребён в каком-то обработчике событий.
Ограничения: условные переходы
Иногда требуется обеспечить, чтобы переход происходил в зависимости от каких-то условий, сложившихся во время выполнения. Ограничения – это булевы функции, по которым определяется, должен ли произойти переход. С их помощью можно добавлять в конечный автомат условную логику, при этом сохраняя чистоту основной части программы.
{ idle: { on: { SUBMIT: [ { target: 'submitting', guard: 'isFormValid' }, { target: 'error', actions: ['showValidationErrors'] } ] } } }
В данном случае событие SUBMIT спровоцирует переход к отправке лишь при условии, что форма заполнена правильно (что определяется при помощи ограничения isFormValid). В противном случае произойдёт переход в состояние “error” и будут показаны ошибки, обнаруженные при валидации. Благодаря ограничениям условная логика остаётся явной и поддаётся тестированию.
Типичные подводные камни и наилучшие практики
Притом, что конечные автоматы очень мощные, многие, кто только начинает осваивать работу с ними, допускают при этой работе типичные ошибки.
Старайтесь не раздувать состояние, для этого держите автомат сфокусированным. Если вы обнаружите, что у вас уже десятки состояний, то попробуйте разбить автомат на несколько более мелких составных автоматов. Каждый автомат должен моделировать одну тесно связанную проблему.
Не пытайтесь решать все задачи при помощи конечных автоматов. Не каждый компонент приложения требуется реализовывать в виде конечного автомата. Простые производные значения, информация для ввода в формы, а также временные состояния UI часто удобнее управляются при помощи более простых подходов. Конечные автоматы нужно применять для сложных потоков задач, где можно чётко очертить как состояния, так и переходы между ними.
Состояния должны быть осмысленными. Каждое состояние должно представлять отдельную фазу вашего потока задач. Старайтесь не создавать таких состояний, которые отличаются только значениями данных. Вместо этого используйте единое состояние с данными контекста. Например, используйте одно состояние “Error” с различными сообщениями об ошибках вместо “NetworkError”, “ValidationError” и т.д.
События должны называться явно. Подбирайте для событий осмысленные названия, по которым понятно, что происходит при каком событии. Например, “BUTTON_CLICKED” хуже, чем “SUBMIT_FORM” или “CANCEL_OPERATION.” Если названия событий подобраны качественно, то ваш конечный автомат получается самодокументируемым.
В каких случаях использовать конечные автоматы
Конечные автоматы наиболее ценны при моделировании потоков задач, в которых выделяются явные фазы и переходы. Попробуйте использовать их для:
Потоков задач, связанных с аутентификацией, где пользователи постепенно проходят через такие этапы как «не аутентифицирован», «аутентифицирован», «время сеанса истекло».
Многоступенчатые формы или установщики, где программа проводит пользователя через серию шагов, каждый из которых отдельно валидируется.
Сложные компоненты пользовательского интерфейса, такие как видеоплеер, виджеты для загрузки файлов или интерактивные руководства, в которых предусматривается множество режимов работы и вариантов поведения.
Оркестрация API, при которой требуется координировать множество запросов, обрабатывать повторные попытки и управлять состояниями загрузки.
Игровая логика, где персонажи, враги и состояния игры развиваются в соответствии с чётко определёнными правилами.
В более простых сценариях, таких, как переключение булевых значений или управление списком элементов более приемлемы традиционные способы управления состоянием. Конечные автоматы помогают структурировать и обезопасить программу, но при их использовании возникают издержки. Самое важное — уметь различать, когда ценность автомата перевешивает эти издержки.
Что дальше
Концептуально понять конечные автоматы — это только начало. На следующем этапе нужно научиться эффективно ими пользоваться в продакшене. Для этого разработаны целые библиотеки, например, XState, в которых предоставляются мощные абстракции, отличная поддержка TypeScript и инструменты, благодаря которым конечные автоматы становятся удобны в реальной разработке.
P.S. Напоминаем, что у нас на сайте проходит сезонная распродажа.
itGuevara
На них основана Switch-технология