Масштабирование состояния в React: от хаоса к ясности через State Machines

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

javascript
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [retries, setRetries] = useState(0);

  useEffect(() => {
    const fetchData = async () => {
      setLoading(true);
      try {
        const data = await fetchUser(userId);
        setUser(data);
        setError(null);
      } catch (err) {
        setError(err.message);
        setRetries(prev => prev + 1);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [userId]);

  if (loading) return <Spinner />;
  if (error) return (
    <ErrorDialog 
      message={error} 
      onRetry={() => setRetries(prev => prev + 1)} 
    />
  );
  return <ProfileCard user={user} />;
}

Это знакомый паттерн, но в реальных приложениях он стремительно усложняется. Добавляем валидацию, таймауты, кеширование, индикаторы обновления – и код превращается в спутанные условия. Проблема лежит глубже синтаксиса: мы моделируем состояния как независимые булевы флаги, тогда как на самом деле они строго взаимосвязаны.

Каскадные состояния – самый частый источник ошибок в компонентах:

  • Одновременное установка loading=true и error=true
  • Попытки повторной загрузки при уже активном запросе
  • Гонки при быстром изменении параметров
  • Непредусмотренные побочные эффекты при переходе между состояниями

Конечные автоматы: структурирование хаоса

Конечный автомат (state machine) — математическая модель с ключевыми свойствами:

  1. Конечное количество внутренних состояний (idle, loading, success, error)
  2. Чётко определённые переходы между ними
  3. Сторонние эффекты, привязанные к переходам

Визуализируем работу нашего компонента:

text
[IDLE] → fetch → [LOADING]
[LOADING] → success → [SUCCESS]
[LOADING] → failure → [ERROR]
[ERROR] → retry → [LOADING] 
[SUCCESS] → refetch → [LOADING]

Реализация в React без библиотек

Создадим хук, явно управляющий состояниями:

typescript
import { useReducer, useEffect } from 'react';

type State = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: any }
  | { status: 'failure'; error: string };

type Event =
  | { type: 'FETCH' }
  | { type: 'RESOLVE'; data: any }
  | { type: 'REJECT'; error: string };

function reducer(state: State, event: Event): State {
  switch (state.status) {
    case 'idle':
      if (event.type === 'FETCH') {
        return { status: 'loading' };
      }
      break;
    case 'loading':
      if (event.type === 'RESOLVE') {
        return { status: 'success', data: event.data };
      }
      if (event.type === 'REJECT') {
        return { status: 'failure', error: event.error };
      }
      break;
    case 'failure':
      if (event.type === 'FETCH') {
        return { status: 'loading' };
      }
      break;
    case 'success':
      if (event.type === 'FETCH') {
        return { status: 'loading' };
      }
      break;
  }
  return state; // Неизменное состояние для необработанных событий
}

function useAsyncMachine(fetchFn) {
  const [state, dispatch] = useReducer(reducer, { status: 'idle' });

  const trigger = () => dispatch({ type: 'FETCH' });

  useEffect(() => {
    if (state.status !== 'loading') return;
    
    let isActive = true;
    
    fetchFn()
      .then(data => isActive && dispatch({ type: 'RESOLVE', data }))
      .catch(error => isActive && dispatch({ type: 'REJECT', error }));
    
    return () => { isActive = false; };
  }, [state.status, fetchFn]);

  return [state, trigger];
}

Критические преимущества:

  1. Все состояния предсказуемы. При loading: true невозможно вызвать новый запрос
  2. Эффекты привязаны к переходам состояния
  3. Невалидные события явно игнорируются
  4. Легкое расширение: добавляем состояния timeouts, кеширования без взрыва сложности

Реальный пример: обработка граничных условий

Усложним требованием: повторная автоматическая загрузка при 500-й ошибке с ограниченным числом попыток. Реализуем через расширение модели:

typescript
type State =
  // ... предыдущие состояния
  | { status: 'failure'; error: string; retries: number };

function reducer(state: State, event: Event): State {
  switch (state.status) {
    // ... предыдущие условия
    case 'failure':
      if (event.type === 'FETCH' && state.retries < 3) {
        return { status: 'loading' };
      }
      break;
  }
  return state;
}

// Добавляем логику при ошибке:
case 'loading':
  if (event.type === 'REJECT') {
    return { 
      status: 'failure', 
      error: event.error,
      retries: state.status === 'failure' 
        ? state.retries + 1 
        : 0
    };
  }

Когда автоматы становятся необходимостью

Состояния компонента становятся кандидатами на автоматы при наличии:

  • Перекрывающихся булевых флагов (loading, success, error)
  • Категорической невозможности одновременных состояний
  • Асинхронных операций с временными зависимостями
  • Бизнес-логики с ограничениями переходов (например: «нельзя отменить завершенный заказ»)

Библиотечные решения, использующие эту концепцию:

  • XState — полноценная реализация Statecharts
  • React Query — загрузка данных как конечное состояние
  • Zustand с паттерном state-slices

Практические рекомендации

  1. Начинайте с явной визуализации диаграммы состояний перед написанием кода
  2. Весь побочный код (API вызовы, таймеры) управляется только через события
  3. Используйте TypeScript для строгой типизации состояний и событий
  4. Работая в команде, документируйте машины через state diagrams
  5. Не бойтесь смешивать Zustand/XState с локальными useReducer автоматами

Почему это работает на масштабе?

Представьте модификацию требования: «при ошибке 401 выполнить refresh token и повторить запрос». При подходе с флагами это означало бы полный рефакторинг. В модели автомата достаточно добавить состояние refreshing и переходы:

text
[FAILURE] — 401 → [REFRESHING]
[REFRESHING] → success → [LOADING]
[REFRESHING] → failure → [LOGOUT] // критическая ошибка

Код компонента остаётся структурно цельным, с добавлением всего одного сотояния и двух новых событий в редьюсер.

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