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

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

## Почему такие 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. Они убирают случайные состояния, а не латать их потом по ходу дела. Вы задаете разрешенные состояния, задаете разрешенные переходы и блокируете все остальное. Код проще ревьюить, проще тестировать и проще считать надежным, когда пользователи ведут себя неожиданно.

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

Начните не с кода. Откройте продукт, пройдите через 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` сразу говорит, что пользователь может делать дальше. Размытые названия тянут за собой размытый код.

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

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

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 разработке. Такой внешний разбор помогает команде сделать модель состояний аккуратнее, прежде чем маленький баг превратится в долгую переделку.
