Рассмотрим ставший почти классикой компонент:
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) — математическая модель с ключевыми свойствами:
- Конечное количество внутренних состояний (
idle
,loading
,success
,error
) - Чётко определённые переходы между ними
- Сторонние эффекты, привязанные к переходам
Визуализируем работу нашего компонента:
[IDLE] → fetch → [LOADING]
[LOADING] → success → [SUCCESS]
[LOADING] → failure → [ERROR]
[ERROR] → retry → [LOADING]
[SUCCESS] → refetch → [LOADING]
Реализация в React без библиотек
Создадим хук, явно управляющий состояниями:
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];
}
Критические преимущества:
- Все состояния предсказуемы. При
loading: true
невозможно вызвать новый запрос - Эффекты привязаны к переходам состояния
- Невалидные события явно игнорируются
- Легкое расширение: добавляем состояния timeouts, кеширования без взрыва сложности
Реальный пример: обработка граничных условий
Усложним требованием: повторная автоматическая загрузка при 500-й ошибке с ограниченным числом попыток. Реализуем через расширение модели:
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
Практические рекомендации
- Начинайте с явной визуализации диаграммы состояний перед написанием кода
- Весь побочный код (API вызовы, таймеры) управляется только через события
- Используйте TypeScript для строгой типизации состояний и событий
- Работая в команде, документируйте машины через state diagrams
- Не бойтесь смешивать Zustand/XState с локальными useReducer автоматами
Почему это работает на масштабе?
Представьте модификацию требования: «при ошибке 401 выполнить refresh token и повторить запрос». При подходе с флагами это означало бы полный рефакторинг. В модели автомата достаточно добавить состояние refreshing
и переходы:
[FAILURE] — 401 → [REFRESHING]
[REFRESHING] → success → [LOADING]
[REFRESHING] → failure → [LOGOUT] // критическая ошибка
Код компонента остаётся структурно цельным, с добавлением всего одного сотояния и двух новых событий в редьюсер.
Состояния пользовательских интерфейсов фундаментально дискретны и конечны. Используя модели, соответствующие этой природе, мы предотвращаем целые классы ошибок. Это не добавляет навязанной сложности, а устраняет случайную сложность – разницу между кодовой базой, которую боятся изменять, и системой, спокойно принимающей новые требования.