08 мар. 2026 г.·6 мин чтения

Машины состояний в React для checkout и approval flows

Узнайте, как машины состояний в React делают checkout и approval flows проще для понимания и уменьшают количество edge-case багов по сравнению с разрозненными boolean-флагами.

Машины состояний в React для checkout и approval flows

Почему такие flow ломаются так легко

Checkout и approval screens редко остаются простыми надолго. Запрос запускается, падает, повторяется, а пользователь в это время меняет поле. Страница все еще помнит старое сообщение об ошибке или успехе. В итоге один и тот же экран пытается быть loading, editable, failed и finished одновременно.

Обычно все начинается с отдельных boolean-флагов. Компонент получает isLoading, hasError, isSubmitted, isEditing, а иногда еще и isRetrying. Каждый флаг по отдельности звучит разумно. Вместе они могут описывать состояния, которых вообще не должно существовать.

Checkout form — знакомый пример. Пользователь нажимает «Pay», запрос стартует, и isLoading становится true. Оплата не проходит, поэтому hasError становится true. Потом пользователь меняет поле карты, но isSubmitted так и не сбрасывается. Теперь экран может показывать баннер с ошибкой, активную кнопку «Place order» и кнопку «View receipt», оставшуюся от старого успешного сценария. Код честно отработал все флаги. Но экран все равно ощущается сломанным.

Approval flows ломаются точно так же. Запрос может ждать проверки, вернуться на доработку, быть одобренным или заблокированным. Если разные части страницы хранят разные флаги, они расходятся. Один блок все еще считает, что элемент можно редактировать, а другой уже переключился в approved. Пользователи замечают это быстро. Кнопки выглядят не так, отключенные поля снова оживают, а сообщения перестают совпадать с текущим шагом.

Async-поведение делает все еще хуже. Повторные попытки, медленные сети, кнопка «назад» в браузере, сохраненные черновики и auto-refresh добавляют проблемы с таймингом. Когда состояние живет в разрозненных флагах по разным компонентам, каждый пограничный случай требует еще одной заплатки.

Именно поэтому state machines так хорошо подходят для этих потоков. Одно понятное состояние и явные переходы к следующему гораздо сложнее сломать, чем набор флагов, которые тихо расходятся между собой.

Что такое машина состояний простыми словами

Состояние — это одно именованное положение, в котором находится интерфейс. Если можно показать на экран и сказать: «пользователь вводит оплату» или «запрос ждет одобрения», значит это state.

Переход — это событие, которое переводит интерфейс из одного состояния в другое. Его может вызвать нажатие кнопки. Или ответ сервера, таймаут, или то, что пользователь изменил форму после неудачной оплаты.

В React правило простое: приложение может находиться только в одном разрешенном состоянии за раз и может переходить только по заранее описанным путям. Звучит скромно, но это сильно сокращает хаотичное поведение.

Возьмем checkout flow. Экран может проходить через cart, shipping, payment, processing, payment_failed и order_complete. Каждое название совпадает с тем, что пользователь реально видит.

Теперь подумаем о кнопках. В payment пользователь может нажать «Pay». В payment_failed он может нажать «Try again» или «Edit card». В order_complete кнопка оплаты должна исчезнуть. Состояние само решает, какие действия имеют смысл.

Та же идея работает и для approval workflow. Запрос может быть draft, submitted, approved, rejected или changes_requested. Если запрос все еще в draft, кнопка «Approve» не имеет смысла. Если он уже approved, кнопка «Submit for review» не должна появляться.

Именно здесь машины состояний и оправдывают себя. Невозможные комбинации просто не существуют по замыслу. У вас не получится экран, который одновременно «submitting» и «approved», или checkout, показывающий сообщение об успехе, пока форма все еще просит данные карты.

Сдвиг довольно прямолинейный. Вместо того чтобы раскидывать isLoading, isApproved, hasError и showSuccess по разным компонентам, вы называете реальные ситуации и события, которые переводят между ними. Интерфейс становится легче читать, а странным багам остается меньше мест, где можно спрятаться.

Две простые карты flow

Карта flow становится полезной, когда отвечает на один вопрос: «Что может случиться дальше?» Именно поэтому этот подход так хорошо работает для checkout и approval screens. Он превращает расплывчатые правила UI в короткий список разрешенных переходов и делает запрещенные переходы очевидными.

Checkout flow часто выглядит так:

  • cart -> address
  • address -> payment
  • payment -> review
  • review -> paid
  • payment -> failed

В реальных продуктах появляются ответвления. Из address клиент может вернуться в cart. Из failed он может повторить попытку. Из review он может изменить адрес или способ оплаты, прежде чем подтвердить заказ. Действие cancel обычно возвращает его в cart или завершает сессию. Оно не должно перепрыгивать в paid.

Некоторые переходы никогда не должны происходить. cart -> paid — недопустимо. failed -> paid — недопустимо, если только пользователь не завершил еще одну попытку оплаты. paid -> payment внутри той же checkout machine обычно не имеет смысла. Если есть refund, то это часто отдельный flow, а не скрытый шаг назад.

Approval flow короче, но правило остается тем же:

  • draft -> submitted
  • submitted -> approved
  • submitted -> rejected
  • rejected -> draft

Эта карта показывает UI, что нужно отображать. В draft пользователь может редактировать и отменять. В submitted он может отозвать заявку, если проверка еще не закончилась. В rejected он может внести правки и отправить снова. В approved элемент уже завершен, поэтому элементы редактирования обычно исчезают.

Запрещенные переходы не менее важны, чем разрешенные. draft -> approved происходить не должен. rejected -> approved не должен происходить без нового шага submitted. approved -> submitted обычно бессмыслица, если только вы не создаете новую версию.

Вот в чем практическая польза. Одна карта рассказывает одну и ту же историю всем компонентам. Кнопки, загрузчики, ошибки и подтверждения перестают придумывать собственные правила.

Почему booleans разносят баги по компонентам

Обычно команды начинают с безобидных флагов: isLoading, isSubmitting, hasError, isComplete. Один компонент владеет одним флагом, хук — другим, а API call переключает третий. Сначала это кажется простым.

Потом на экран приходит реальное поведение пользователей. Кто-то редактирует форму после неудачной оплаты, обновляет страницу во время approval или нажимает submit дважды. Интерфейс попадает в состояния, которые никто не собирался делать.

Вот несколько типичных комбинаций:

  • isSubmitting = true и hasError = true
  • isComplete = true при isLoading = true
  • hasError = false, но форма все еще заблокирована
  • isSubmitting = false, хотя запрос еще не завершился

Каждый флаг по отдельности имеет смысл. Вместе они создают слишком много комбинаций, которые невозможно удержать в голове. Вы перестаете видеть понятный flow и начинаете решать логическую головоломку.

Отладка замедляется по той же причине. Если в баг-репорте написано: «spinner остался после изменения approval», приходится искать props, hooks, effects и async handlers в нескольких файлах. А вот именованное состояние вроде awaiting_payment, payment_failed, waiting_for_approval или approved рассказывает историю намного быстрее.

Это важно и на code review. Когда flow описан явно, ревьюер может задавать прямые вопросы. Может ли waiting_for_approval сразу перейти в approved? Что переводит payment_failed в retrying? Что возвращает retrying в ready? Эти проверки конкретны.

С разбросанными флагами ревьюерам приходится вручную угадывать все возможные комбинации. Большинство людей замечает очевидный путь и пропускает странный. Так и просачиваются edge case bugs.

Вот почему state machines так хорошо подходят для checkout и approval flows. Они убирают случайные состояния, а не латать их потом по ходу дела. Вы задаете разрешенные состояния, задаете разрешенные переходы и блокируете все остальное. Код проще ревьюить, проще тестировать и проще считать надежным, когда пользователи ведут себя неожиданно.

Как собрать это шаг за шагом

Упорядочите async-пути
Получите внешнюю помощь с retries, timeouts и server events, из-за которых экраны продолжают ломаться.

Начните не с кода. Откройте продукт, пройдите через checkout или approval flow и выпишите состояния, в которых пользователь действительно может находиться. Для checkout это могут быть cart, entering_details, submitting_payment, paid, failed или canceled. Для approval — draft, pending_review, approved, rejected или reopened.

Потом перечислите события, которые двигают flow. Часть приходит от пользователя, например submit, edit или cancel. Часть — от сервера, например payment_succeeded, payment_failed или review_expired. Команды часто забывают про эти server events, и именно с них обычно начинаются странные баги.

Перед тем как строить компоненты, нарисуйте разрешенные переходы между состояниями. Достаточно простого эскиза с коробками и стрелками. submitting_payment может перейти в paid или failed. Но он не должен прыгать обратно в cart, если вы не добавили для этого настоящее событие.

Простой порядок работы обычно помогает:

  1. Назовите каждое состояние как полную ситуацию, а не маленький флаг.
  2. Назовите каждое событие как то, что произошло.
  3. Решите, какие следующие состояния разрешает каждое событие.
  4. Поместите API call или другой side effect на тот переход, которому он нужен.

Последний шаг особенно важен. Если машина входит в submitting_payment, этот переход может вызвать payment API. Если она входит в approved, этот переход может создать audit log или обновить order. Причина и действие остаются рядом, поэтому retries и error handling становится легче понимать.

В React компонент должен в основном читать текущее состояние и рендерить интерфейс на его основе. Если состояние failed, показывайте экран повторной попытки. Если состояние pending_review, заблокируйте редактирование. Старайтесь не таскать лишние booleans вроде isLoading, isPaid и canEdit. Такие флаги быстро расходятся, особенно когда несколькими частями одного flow владеют разные компоненты.

Одно понятное состояние проще тестировать, проще менять и меньше ломается, когда продуктовая команда добавляет еще один неудобный случай.

Реалистичный пример с повторными попытками и правками

Хорошая проверка для этого подхода — flow, который идет назад после ошибки. Именно там чаще всего всплывают checkout bugs.

Checkout после ошибки оплаты

Покупатель собирает корзину, вводит shipping address и доходит до оплаты. Списание с карты не проходит, потому что банк не отвечает вовремя. Приложение переходит в payment_failed.

В этот момент у покупателя есть два разумных действия. Он может попробовать оплатить еще раз или изменить адрес, потому что замечает ошибку в ZIP code. Машина состояний делает оба пути спокойными — и это хорошо.

Если он нажимает «Edit address», машина переходит в editing_address. Одновременно она очищает сообщение об ошибке оплаты, потому что это сообщение относится к старой попытке. Корзина остается на месте. Выбранный способ доставки можно перепроверить после обновления адреса. На экране показывается форма адреса, а не payment form со старым красным баннером.

После сохранения нового адреса машина может перейти в review_order. Это важно, потому что taxes или shipping cost могли измениться. Сразу вернуть пользователя к вводу карты часто неправильно.

Дальше каждое событие ведет к одному понятному экрану:

  • Retry payment -> payment form
  • Edit address -> address form
  • Save address -> review screen
  • Submit payment -> processing or success

Approval flow с запросом на доработку

Та же идея помогает и в approval workflow. Сотрудник отправляет заявку на покупку. Машина переходит из draft в waiting_for_manager.

Менеджер открывает заявку и видит, что receipt отсутствует. Он нажимает «Request changes». Машина переходит в changes_requested, и сотрудник теперь видит редактируемую форму. Он не видит наполовину approved screen с отключенными полями и предупреждением сверху. Именно такие смешанные состояния обычно создает код с перегруженными booleans.

Когда сотрудник загружает receipt и нажимает resubmit, машина снова переходит в waiting_for_manager. Если менеджер одобряет заявку, машина переходит в approved. В этот момент не возникает ситуации, где approved = true и needsChanges = true спорят друг с другом.

Вот почему этот подход становится спокойнее, когда flow усложняется. Покупатель возвращается к правильному шагу. Менеджер аккуратно отправляет работу назад. Экран каждый раз совпадает со state.

Ошибки, которые команды допускают при внедрении этого подхода

Проверьте состояния интерфейса
Найдите недопустимые переходы до того, как они превратятся в обращения в поддержку и дорогие переделки.

Команды часто рисуют аккуратную машину, а через неделю сами же проделывают в ней дыры. Кто-то добавляет быстрый admin action, который переводит заказ из review сразу в approved. Кто-то еще добавляет shortcut, который помечает checkout завершенным после одного API response. Такие лазейки кажутся безобидными, но они обходят правила, на которых по-прежнему держится остальная часть приложения.

Именно так аккуратный flow снова превращается в угадайку. Если payment, review или approval должны идти по порядку, каждый переход должен проходить через машину. Машина состояний помогает только тогда, когда команда считает ее source of truth, а не еще одним слоем рядом со старыми привычками.

Еще одна частая ошибка — навсегда оставить старые booleans. Команда добавляет машину, но isLoading, isError, isApproved, showRetry и hasTimedOut продолжают жить по разным компонентам. Теперь две системы описывают один и тот же экран. Рано или поздно они расходятся.

В checkout и approval UI это случается особенно часто. Решение простое, хоть и жесткое: как только машина покрывает сценарий, удалите соответствующий boolean.

API calls тоже оказываются слишком во многих местах. Обработчик кнопки отправляет один запрос, эффект — другой, а helper повторяет третий. Когда так происходит, никто не понимает, какое именно действие двинуло экран вперед. Перенесите side effects на смену состояния. Вход в submitting_payment должен запускать payment request. Событие PAYMENT_FAILED должно переводить приложение в payment_failed. Retry должен быть отдельным переходом, а не разрозненным callback.

Команды также забывают о неудобных сценариях, с которыми пользователи сталкиваются каждый день: первый запуск с пустыми данными, медленные запросы, которые должны завершаться по timeout, повторные попытки после ошибки и изменения по запросу после того, как approval уже выглядел финальным. Если машина не моделирует эти случаи, баги просто переезжают в пробелы.

Проблемы могут создавать и сами названия состояний. active, open или done звучат аккуратно, но скрывают смысл. awaiting_manager_approval сразу говорит, что происходит. changes_requested сразу говорит, что пользователь может делать дальше. Размытые названия тянут за собой размытый код.

Когда машина начинает казаться слишком сложной, большинству команд нужны не меньшие, а более понятные состояния.

Быстрые проверки перед релизом

Разберите сценарии сбоев
Разберите retries, edits и timeouts с человеком, который строил сложные системы.

Flow обычно кажется готовым, когда happy path один раз сработал на вашем ноутбуке. Именно в этот момент и просачиваются странные баги. Короткая проверка машины закрывает пробелы раньше, чем это сделают пользователи.

Для checkout и approval screens обычно хватает такого списка:

  • Соотнесите каждый видимый экран с одним именованным состоянием.
  • Проверьте каждую кнопку и пользовательское действие через одно событие.
  • Посмотрите, нет ли комбинаций, которые не должны существовать.
  • Нарисуйте на бумаге error paths.
  • Тестируйте transitions, а не только конечный результат.

Небольшой пример делает это очевидным. Допустим, покупатель нажимает «Pay now», и запрос истекает по timeout. В машине состояний приложение переходит из paying в payment_failed, и UI может показать один понятный выбор: повторить попытку или изменить способ оплаты. В setup, где правят booleans, часто остается крутящийся spinner, все еще отключенная кнопка и никакого чистого пути назад.

Та же проверка помогает и с approval screens. Если менеджер запросил изменения, элемент не должен в другой части страницы все еще считаться approved. Один переход должен обновлять весь flow.

Вот почему state machines in React обычно выходят с меньшим количеством edge case bugs. Вам не нужно гадать, какие флаги сошлись. Вы можете указать на точное состояние, точное событие и точный следующий шаг.

Что делать дальше

Обычно командам удается лучше, если они сначала чинят один болезненный flow, а не пытаются вычистить сразу все приложение. Выберите экран, который постоянно приводит к обращениям в поддержку, странным retry или багам, которые никто не может воспроизвести дважды. Во многих продуктах это checkout или approval workflow.

Используйте текущий хаос как отправную точку. Если пользователь может застрять между «submitting» и «done», или если один компонент считает заказ оплаченным, а другой все еще показывает spinner, этот flow уже готов к переписыванию.

Прежде чем кто-то тронет код, соберите людей, которые знают этот процесс, в одной комнате и нарисуйте схему. Белой доски достаточно. Запишите реальные состояния, а потом — что переводит пользователя из одного состояния в другое. Команды часто замечают пропущенные случаи буквально за десять минут.

Короткий эскиз обычно должен ответить на четыре вопроса:

  • В каких состояниях реально может находиться пользователь?
  • Какое событие двигает flow вперед?
  • Что должно происходить при ошибке или timeout?
  • Когда пользователь может вернуться и что-то изменить?

После этого соберите небольшой proof of concept. Не разносите сразу весь checkout system или все approval screens. Один flow, одна машина, один набор явных transitions — этого достаточно, чтобы понять, делает ли подход ваш код проще для понимания.

Если все работает, превратите это в повторяемый паттерн для команды. Сохраните map состояний. Держите названия событий единообразными. Добавьте несколько тестов переходов, прежде чем переносить подход на другие экраны.

Такая чистка быстро окупается, потому что баги становятся проще. Вы перестаете спрашивать: «Какой boolean разъехался?» — и начинаете спрашивать: «Почему это событие перевело нас в это состояние?»

Если вам нужен второй взгляд на сложный React-flow, Oleg Sotnikov на oleg.is консультирует стартапы и небольшие компании по product architecture, Fractional CTO, инфраструктуре и практической AI-first разработке. Такой внешний разбор помогает команде сделать модель состояний аккуратнее, прежде чем маленький баг превратится в долгую переделку.

Часто задаваемые вопросы

Когда React-flowу нужна машина состояний?

Используйте её, когда экран может проходить через несколько именованных шагов, а пользователи могут идти дальше, сталкиваться с ошибкой, повторять попытку или редактировать данные в середине процесса. Checkout и approval flows хорошо подходят для этого подхода, потому что проблемы с таймингом и старые флаги часто оставляют интерфейс в сломанной смеси состояний.

Booleans когда-нибудь бывают достаточны?

Да, для небольших экранов с одним простым async-действием пары boolean-флагов может хватить. Но как только появляются retries, возврат назад, редактирование после ошибки или изменения со стороны сервера, флаги обычно начинают расходиться, а баги становится труднее отследить.

Какие состояния должны быть в checkout machine?

Начните с тех состояний, которые пользователь действительно видит: cart, address, payment, review, processing, payment_failed и order_complete. Если название состояния не совпадает с реальным экраном или ситуацией, переименуйте его, пока не совпадет.

Как должны работать retries в машине состояний?

Считайте retry отдельным событием и намеренно возвращайте flow в нужное состояние. Например, после payment_failed retry может отправить пользователя в payment или processing, а edit может перевести его в address или review и очистить старую ошибку.

Куда помещать API calls?

Размещайте их на переходах состояний, а не в случайных обработчиках кнопок и эффектах. Когда приложение входит в submitting_payment, запускайте там payment request, а затем отправляйте обратно payment_succeeded или payment_failed, чтобы следующий экран каждый раз следовал тем же правилам.

Как обрабатывать edits после failed payment?

Переведите пользователя в настоящее edit-state и очистите любое сообщение, связанное с неудачной попыткой. После сохранения изменений отправьте его на шаг review, если суммы, tax, shipping или validation могут измениться перед повторной оплатой.

Подходят ли машины состояний для approval screens?

Они очень помогают, потому что approval work строится на строгих шагах и заблокированных переходах. Request может перейти из draft в submitted, а затем в approved, rejected или changes_requested, и интерфейс сможет закрывать или открывать поля на основе одного состояния.

Нужна ли библиотека, чтобы использовать этот подход?

Нет. Можно начать с reducer, обычной карты объектов или любой простой схемы переходов от события к состоянию. Библиотека помогает, когда flow становится большим, но настоящий плюс приходит от ясных названий состояний и переходов, а не от самого инструмента.

Как тестировать машину состояний в React?

Тестируйте переходы, а не только итоговые снимки. Проверяйте, что каждое событие переводит одно разрешенное состояние в следующее ожидаемое, и что запрещенные переходы остаются заблокированными, чтобы приложение не могло перепрыгнуть из draft сразу в approved или из cart прямо в paid.

Как проще всего внедрить это в существующее приложение?

Выберите один болезненный flow и сначала разложите состояния и события на бумаге. Затем соберите машину для этого потока, удалите старые booleans, которые перекрываются с ней, и посмотрите, станут ли баг-репорты понятнее и проще для исправления.