WebMriya
Назад до статей

Стейт-машини в React: замініть прапорці на явні стани UI

Дізнайтеся, як явні стейт-машини спрощують розуміння, тестування й розширення React-інтерфейсів порівняно з набором пов’язаних прапорців.

React
TypeScript
Frontend Architecture
Software Architecture
W
Frontend Architecture & Legacy Modernization
30 вересня 2026 р. · 6 хв читання

Знайома помилка в React часто починається з невеликого запиту: показувати індикатор під час надсилання форми, відобразити повідомлення про успіх після завершення і дозволити користувачу повторити спробу, якщо запит завершився помилкою. Спочатку здається, що достатньо кількох boolean-прапорців: isSubmitting, isSuccess і hasError. Через місяць компонент може показувати суперечливі стани, приймати кліки під час активного запиту або відображати стару помилку після успішної повторної спроби.

Проблема не в тому, що boolean-прапорці погані. Проблема в тому, що незалежні прапорці описують комбінації, у які інтерфейс узагалі не повинен потрапляти. Коли кількість прапорців зростає, справжній стан компонента стає неявним. Стейт-машина робить його явним: перелічує допустимі стани та події, які переміщують UI між ними.

Чому boolean-прапорцям стає складно довіряти

Уявімо компонент завантаження даних із прапорцями isLoading, hasData, hasError і isRefreshing. Кожна змінна окремо зрозуміла. Разом вони дозволяють шістнадцять теоретичних комбінацій, хоча продукт може підтримувати лише кілька. Що має відбутися, коли isLoading і hasError одночасно дорівнюють true? А коли одночасно true є hasData і hasError? Іноді це навмисно: наприклад, потрібно показати застарілі дані разом із помилкою оновлення. Значно частіше це випадковий наслідок асинхронного виконання.

tsx
// Чотири незалежні прапорці: шістнадцять комбінацій, з яких легальні лише кілька.
const [isLoading, setIsLoading] = useState(false);
const [hasData, setHasData] = useState(false);
const [hasError, setHasError] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);

// Ніщо тут не заважає відрендерити дві взаємовиключні гілки одночасно.
return (
  <>
    {hasError && <ErrorBanner onRetry={refetch} />}
    {hasData && <Table rows={rows} />}
    {isLoading && <Spinner />}
  </>
);
  • Компонент може одночасно показати два взаємовиключні повідомлення.
  • Обробник події може виконатися у стані, в якому дія повинна бути заблокована.
  • Пізніша відповідь може перезаписати результат новішого запиту.
  • Сценарій скидання може очистити один прапорець і залишити інший застарілим.
  • Тестам доводиться покривати багато комбінацій, які важко назвати й пояснити.

Boolean-прапорці також приховують переходи. Значення isSubmitting показує, що істинно зараз, але не пояснює, чи форма прийшла до цього стану з idle, після помилки валідації або після попереднього надсилання. Ця історія важлива, коли потрібно вирішити, чи очищати помилку, зберігати введені дані або ігнорувати повторну подію.

Моделюйте UI як стани та події

Модель стейт-машини розділяє два поняття: поточний стан і подію, яка може спричинити перехід. Для форми станами можуть бути idle, submitting, succeeded і failed. Подіями — SUBMIT, RESOLVE, REJECT і RESET. Ключове рішення полягає в тому, що кожен стан визначає, які події для нього мають сенс.

СтанДопустима подіяНаступний станТиповий UI
idleSUBMITsubmittingРедагована форма та кнопка надсилання
submittingRESOLVEsucceededЗаблокована форма та індикатор прогресу
submittingREJECTfailedФорма заблокована до завершення запиту
failedSUBMITsubmittingПовідомлення про помилку та дія повторної спроби
succeededRESETidleПовідомлення про успіх або очищена форма

Така структура не дозволяє виконувати неможливі переходи. Подія RESOLVE, отримана в стані idle, не матиме ефекту. Другу подію SUBMIT під час submitting можна свідомо проігнорувати або обробити окремим правилом. Подію RESET можна дозволити зі станів succeeded і failed, не запам’ятовуючи, які саме прапорці потрібно очистити.

Представте модель у TypeScript

Для невеликих компонентів часто достатньо дискримінованого union type. Він дає компілятору точний опис допустимих станів і ускладнює випадкове перенесення застарілих даних. Поля, специфічні для стану, можна додавати лише там, де вони справді мають сенс: помилка належить failed, а відповідь сервера — succeeded.

ts
type FormState =
  | { status: 'idle' }
  | { status: 'submitting'; requestId: number }
  | { status: 'succeeded'; response: SubmitResponse }
  | { status: 'failed'; error: string };

type FormEvent =
  | { type: 'SUBMIT'; requestId: number }
  | { type: 'RESOLVE'; requestId: number; response: SubmitResponse }
  | { type: 'REJECT'; requestId: number; error: string }
  | { type: 'RESET' };

Reducer має бути єдиним місцем, яке визначає, як події змінюють стан. Якщо зібрати переходи в одну чисту функцію, поведінку легше читати, незалежно тестувати й не прив’язувати до порядку, у якому React обробляє події.

ts
function formReducer(state: FormState, event: FormEvent): FormState {
  switch (state.status) {
    case 'idle':
    case 'failed':
      // Тут має сенс лише SUBMIT; будь-яка інша подія ігнорується.
      return event.type === 'SUBMIT' ? { status: 'submitting', requestId: event.requestId } : state;

    case 'submitting':
      // Результат, що належить старішому запиту, більше не є активним.
      if (event.type === 'RESOLVE' && event.requestId === state.requestId)
        return { status: 'succeeded', response: event.response };
      if (event.type === 'REJECT' && event.requestId === state.requestId)
        return { status: 'failed', error: event.error };
      return state;

    case 'succeeded':
      return event.type === 'RESET' ? { status: 'idle' } : state;
  }
}

Відокремлюйте асинхронну роботу від правил переходів

Reducer може описувати життєвий цикл запиту, але не повинен виконувати сам запит. Запускайте асинхронну операцію в обробнику події, ефекті або окремому шарі роботи з даними, а після завершення надсилайте подію з результатом. Так побічні ефекти залишаються поза логікою переходів.

Є важлива проблема, яку потрібно врахувати: старий запит може завершитися після нового. Стейт-машина автоматично не скасовує запити й не розрізняє їх. Додайте ідентифікатор запиту, AbortController або бібліотеку для роботи з даними, яка відстежує актуальність запитів. Перехід має приймати відповідь лише тоді, коли вона належить активному запиту.

tsx
const [state, dispatch] = useReducer(formReducer, { status: 'idle' });
const lastRequestId = useRef(0);

async function handleSubmit(values: FormValues) {
  if (state.status === 'submitting') return; // повторний клік нічого не змінює
  const requestId = ++lastRequestId.current;
  dispatch({ type: 'SUBMIT', requestId });

  try {
    const response = await submitForm(values);
    dispatch({ type: 'RESOLVE', requestId, response });
  } catch (error) {
    dispatch({ type: 'REJECT', requestId, error: String(error) });
  }
}
  1. Створіть ідентифікатор під час переходу в стан завантаження.
  2. Додайте цей ідентифікатор до запиту та події з його результатом.
  3. Ігноруйте результат, ідентифікатор якого більше не є активним.
  4. Скасовуйте роботу, яка більше не має значення після зміни компонента або запиту.
  5. Протестуйте обидва порядки завершення: спочатку новий запит і спочатку старий.

Не перетворюйте кожен компонент на фреймворк

Стейт-машина корисна, коли компонент має взаємовиключні режими, асинхронні переходи, повторні спроби, права доступу або кілька дій, допустимість яких залежить від поточного режиму. Вона зайва для незалежних налаштувань відображення, таких як isSidebarOpen або isMuted, якщо ці значення не обмежують одне одного.

Почніть із union type і reducer, перш ніж підключати бібліотеку стейт-машин. Бібліотека виправдана, коли граф станів великий, використовується на кількох екранах, містить паралельні стани або команді потрібні візуалізація й стандартизовані інструменти. Мета не в тому, щоб додати більше абстракцій, а в тому, щоб зробити видимими вже наявні правила домену.

Практичний чекліст для рев’ю

  • Чи можете ви назвати кожен допустимий стан простою мовою?
  • Чи може UI потрапити в суперечливу комбінацію прапорців?
  • Які події ігноруються в кожному стані?
  • Де обробляються гонки запитів, скасування та повторні спроби?
  • Чи зберігаються специфічні для стану значення лише в потрібних станах?
  • Чи можна протестувати логіку переходів без рендерингу компонента?
  • Чи зрозуміє новий інженер життєвий цикл, не простежуючи кілька ефектів?

Найбільша перевага — не в меншій кількості коду. Перевага в меншій площі для міркувань. Коли інтерфейс може перебувати в одному з шести названих станів, а не в недокументованій комбінації прапорців, поведінку продукту легше обговорювати, крайні випадки простіше тестувати, а зміни рідше створюють новий неможливий стан.

Цей підхід особливо цінний у складних вебзастосунках, де взаємодіють форми, фонове оновлення, оптимістичні зміни та права доступу. Під час рев’ю frontend-архітектури або модернізації legacy-системи можна спочатку перетворити на явні стани найкрихкіші workflow, не переписуючи весь застосунок.

Поширені запитання

Чи потрібна бібліотека стейт-машин для кожного стану в React?

Ні. Для багатьох локальних workflow достатньо дискримінованого union type і reducer. Бібліотека виправдана для великих або спільних графів станів, паралельних станів чи потреби у візуалізації та стандартизованих інструментах.

Чи завжди boolean-прапорці є поганим вибором?

Ні. Незалежні прапорці доречні, якщо їхні значення не описують взаємовиключні режими й не обмежують одне одного. Тривожний сигнал — набір прапорців, допустимі комбінації яких складно пояснити.

Чи може стейт-машина запобігти гонкам між запитами?

Вона допомагає явно описати стани запиту та допустимі події, але автоматично не скасовує й не ідентифікує асинхронну роботу. Використовуйте ідентифікатори запитів, AbortController або інструмент для роботи з даними, який підтримує актуальність результатів.