Любое взаимодействие пользователя с интерфейсом порождает асинхронные операции: запросы к API, чтение файлов, таймеры. Неуправляемое состояние во время этих операций приводит к багам, которые трудно воспроизвести – мигающим интерфейсам, гонкам запросов или "зомби"-компонентам, обновляющим несуществующий DOM. Рассмотрим архитектурные паттерны для детерминированного контроля асинхронных состояний.
Проблема: Слепые зоны асинхронности
Типичный пример антипаттерна:
function UserProfile() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch('/api/user')
.then(res => res.json())
.then(data => setUser(data));
}, []);
if (!user) return <Spinner />;
return <h1>{user.name}</h1>;
}
Кажется логичным? Но здесь скрыты проблемы:
- Утечки памяти: Компонент может размонтироваться до завершения запроса, но
setUser
вызовется для несуществующего компонента - Гонки данных: При быстром переключении между профилями запросы приходят в непредсказуемом порядке
- Отсутствие обработки ошибок
- Невозможность переиспользования логики
Паттерн: Объявленный статус операции
Первый шаг – явно моделировать состояние асинхронной операции:
type AsyncState<T> = (
| { status: 'idle' }
| { status: 'pending' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
);
Использование в компоненте:
function UserProfile() {
const [state, setState] = useState<AsyncState<User>>({ status: 'idle' });
useEffect(() => {
setState({ status: 'pending' });
fetch('/api/user')
.then(response => {
if (!response.ok) throw new Error('Server error');
return response.json();
})
.then(data => setState({ status: 'success', data }))
.catch(error => setState({ status: 'error', error }));
}, []);
switch (state.status) {
case 'idle':
return <button onClick={loadData}>Load</button>;
case 'pending':
return <Spinner />;
case 'error':
return <ErrorDisplay error={state.error} />;
case 'success':
return <Profile data={state.data} />;
}
}
Почему это работает:
- Исключены промежуточные состояния (например,
user
частично заполнен при загрузке) - Четкое разделение представлений по состояниям
- Обработка всех жизненных циклов операции
Контроль жизненного цикла: Отмена запросов
Для отмены запросов при размонтировании или изменении параметров используем AbortController
:
useEffect(() => {
const controller = new AbortController();
setState({ status: 'pending' });
fetch('/api/user', { signal: controller.signal })
.then(/* ... */)
.catch(error => {
if (error.name !== 'AbortError') {
setState({ status: 'error', error });
}
});
return () => controller.abort();
}, [userId]);
Критично для:
- Страниц с динамическими параметрами (роутинг)
- Быстрых взаимодействий пользователя
- Предотвращения сетевого thrashing
Уровень приложения: Глобальные асинхронные зависимости
При загрузке данных, необходимых нескольким компонентам (конфиг, A/B-тесты), используем контекст с интеграцией статусов:
const RuntimeConfigContext = createContext<AsyncState<Config>>(
{ status: 'idle' }
);
export function RuntimeProvider({ children }) {
const [state, setState] = useState<AsyncState<Config>>({ status: 'pending' });
useEffect(() => {
initializeAppConfig()
.then(config => setState({ status: 'success', data: config }))
.catch(error => setState({ status: 'error', error }));
}, []);
return (
<RuntimeConfigContext.Provider value={state}>
{state.status === 'success' ? children : <AppLoader />}
</RuntimeConfigContext.Provider>
);
}
// В компоненте
const configState = useContext(RuntimeConfigContext);
if (configState.status !== 'success') return null;
Оптимизация: Скелетоны вместо спиннеров
При последовательных запросах "спиннеры" вызывают мерцание интерфейса. "Скелетоны" улучшают восприятие непрерывности:
function UserProfile() {
const { status, data, error } = useUser();
return (
<div>
{status === 'pending' && <ProfileSkeleton />}
{status === 'error' && <ErrorMessage error={error} />}
{status === 'success' && (
<>
<Avatar src={data.avatar} />
<ProfileDetails details={data.details} />
</>
)}
</div>
);
}
Принципы скелетонов:
- Занимают пространство конечного контента
- Анимируются только декоративно (pulse, wave)
- Не блокируют параллельные статичные элементы
Реактивный ренессанс: Использование таймеров и потоков
Для сложных сценариев (автодополнение, связанные виджеты) применяйте RxJS. Пример дебаунсинга с отменой:
import { fromEvent, timer } from 'rxjs';
import { debounce, switchMap, takeUntil } from 'rxjs/operators';
const searchInput = document.getElementById('search');
fromEvent(searchInput, 'input').pipe(
debounce(() => timer(300)),
switchMap(event => {
cancelPreviousRequest();
return fetchResults(event.target.value);
}),
takeUntil(unmount$)
);
Почему RxJS:
- Композиция асинхронных ивентов как потоков
- Встроенные стратегии переключения/отмены
- Тестируемость через marble-диаграммы
Баланс нагрузки: Статус против дизайн-токенов
В сложных интерфейсах избегайте смешивания состояний. Пример антипаттерна:
<Button
disabled={isLoading}
className={isError ? 'error' : ''}
>
{isLoading ? <Spinner /> : 'Submit'}
</Button>
Вместо этого использовайте дескриптивные пропы:
<AsyncActionButton
actionStatus={status}
idleText="Submit"
// errorText="Retry"
// loadingComponent={<CompactSpinner />}
/>
Это стирает границу между UX и дизайн-системой.
Интеграция с состоянием приложения
Подход универсален для стейт-менеджеров. Redux Toolkit с thunks:
// Slice
const usersSlice = createSlice({
name: 'users',
initialState: { status: 'idle', data: null },
reducers: {
loadStarted(state) {
state.status = 'pending';
},
loadSuccess(state, action) {
state.status = 'success';
state.data = action.payload;
},
loadFailed(state, action) {
state.status = 'error';
state.error = action.payload;
}
}
});
// Thunk
const fetchUser = (userId) => async (dispatch) => {
dispatch(loadStarted());
try {
const response = await fetch(`/api/users/${userId}`);
dispatch(loadSuccess(await response.json()));
} catch (error) {
dispatch(loadFailed(error.message));
}
};
Заключительные рекомендации
- Декларация выше империатива: Описывайте что должно отображаться по состоянию, а не когда что обновлять
- Моделируйте конечное состояние автоматом: Библиотеки типа XState полезны для сложных цепочек
- Ниспровержение данных: Передавайте статус без проп-дриллинга через “prop tunneling”
- Тестируйте переходы состояний: Jest/RTL для граничных состояний успех/ошибка/загрузка
- Централизуйте контракты данных: TypeScript Interfaces для ответов API и стейтов
Правильное управление асинхронными состояниями превращает хаотичные загрузки в предсказуемый поток данных. Когда каждый статус представления прозрачно соответствует бизнес-состоянию системы, интерфейс становится не пикселями, а визуализацией стабильных процессов. Фокус смещается с борьбы с багами на плавное создание возможностей.