Привет, Хабр!
При разработке фронтенда мы часто ограничиваемся тремя состояниями UI‑запроса: loading, error, data (или success). Но это не всегда достаточно — особенно когда дело доходит до тонких UX‑деталей, предотвращения гонок запросов и адекватного управления отменой при размонтировании компонентов. В статье рассмотрим, почему добавление состояния idle делает систему более надёжной, как реализовать конечный автомат для управления статусами, как отменять fetch‑запросы и оптимизировать перерисовки.
Почему три состояния (loading, error, data) — недостаточно
Проблема с race conditions
Представьте, вы в компоненте при useEffect
запускаете fetch‑логику, но пользователь быстро переключает табы или параметр запроса меняется сразу дважды. Первый запрос ещё грузится, второй уже отправлен, и когда первый возвращается, он перезаписывает данные второго. В итоге UI отображает устаревшие данные — привет гонки запросов.
Новый запрос пришёл до завершения старого
Как нормально отлавливать ситуацию, когда новый запрос отменяет старый? Если вы просто ставите loading = true
при каждом вызове, первое значение loading → false
из старого запроса снимет индикатор загрузки раньше времени.
Всплывающая ошибка при уходе со страницы
Ещё забавнее: вы уходите со страницы, старый запрос успел упасть с ошибкой, setState({ error })
вызывает ворнинг «setState on unmounted component». Или worse — крэшите приложение.
Модель “finite state machine” в UI и как её реализовать руками
Чтобы бороться с хаосом статусов, полезно формализовать логику через конечный автомат (FSM).
// types/loadingMachine.ts
export type Status = 'idle' | 'loading' | 'success' | 'error';
export interface State<T> {
status: Status;
data: T | null;
error: Error | null;
}
export type Action<T> =
| { type: 'FETCH_INIT' }
| { type: 'FETCH_SUCCESS'; payload: T }
| { type: 'FETCH_FAILURE'; error: Error }
| { type: 'RESET' };
export function dataFetchReducer<T>(
state: State<T>,
action: Action<T>
): State<T> {
switch (action.type) {
case 'FETCH_INIT':
// можно не чистить старые данные: это опционально — зависит от UX
return { ...state, status: 'loading', error: null };
case 'FETCH_SUCCESS':
return { status: 'success', data: action.payload, error: null };
case 'FETCH_FAILURE':
return { status: 'error', data: null, error: action.error };
case 'RESET':
return { status: 'idle', data: null, error: null };
default:
return state;
}
}
Логика конечного автомата предельно ясна: когда вы впервые вызываете fetch — переводите машину из состояния idle в loading (это сигнал UI начать показывать индикатор), при этом прошлые данные обычно остаются до прихода новых; затем, если сервер вернул ответ с кодом 200–299, вы делаете loading → success, записываете новый payload в data
и сбрасываете ошибку; если же запрос бросил исключение или вернулся статус вне диапазона «ОК», вы делаете loading → error, очищаете или оставляете прежние данные в зависимости от UX и сохраняете объект ошибки; наконец, любое текущее состояние можно сбросить в idle (например, по клику «Повторить» или при размонтировании компонента), чтобы подготовить машину к новым запросам без артефактов прошлого. При этом важно внедрить «гарды» — то есть игнорировать события success
или failure
, если они приходят от уже отменённого запроса, чтобы избежать race conditions, и аккуратно управлять AbortController
, сбрасывая стейт по RESET
, а не при каждом новом fetch, чтобы UI не прыгал и данные не «мигали».
Пример использования в компоненте:
import React, { useEffect, useReducer, useRef } from 'react';
import { dataFetchReducer, State, Status } from './types/loadingMachine';
function useFetch<T>(url: string): State<T> & { refetch: () => void } {
const initialState: State<T> = {
status: 'idle',
data: null,
error: null,
};
const [state, dispatch] = useReducer(dataFetchReducer<T>, initialState);
const abortControllerRef = useRef<AbortController | null>(null);
const latestUrlRef = useRef(url);
const fetchData = () => {
// Если есть запущенный запрос — отменяем
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
dispatch({ type: 'FETCH_INIT' });
fetch(latestUrlRef.current, { signal: controller.signal })
.then(res => {
if (!res.ok) {
throw new Error(`HTTP error: ${res.status}`);
}
return res.json() as Promise<T>;
})
.then(data => {
dispatch({ type: 'FETCH_SUCCESS', payload: data });
})
.catch(err => {
if (err.name === 'AbortError') {
// Отмена — молча игнорируем
return;
}
dispatch({ type: 'FETCH_FAILURE', error: err });
});
};
useEffect(() => {
latestUrlRef.current = url;
fetchData();
// При размонтировании — отменяем текущий запрос и сбрасываем
return () => {
abortControllerRef.current?.abort();
dispatch({ type: 'RESET' });
};
}, [url]);
return { ...state, refetch: fetchData };
}
Мы не меняем старые данные на null
при FETCH_INIT
. Так UI остаётся «заполненным» до тех пор, пока не придут новые данные — иногда это более UX‑френдли.
Управление отменой
Важно не оставлять «висящих» fetch»ей, иначе при быстром переходе между экранами вы получите ворнинги и гонки данных. За отмену отвечает встроенный в браузеры объект AbortController: вы создаёте его, передаёте signal
в fetch
, а при необходимости вызываете controller.abort()
.
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(res => res.ok ? res.json() : Promise.reject(res.statusText))
.then(data => setData(data))
.catch(err => {
if (err.name !== 'AbortError') setError(err);
});
// При размонтировании компонента отменяем запрос
return () => controller.abort();
}, [url]);
Любой незавершённый запрос будет прекращён в момент return
из useEffect
, и вы больше не получите setState
на уже несуществующем компоненте. Если нужно, внутри return
можно также сбросить статус в idle
, чтобы FSM‑редьюсер вернулся в исходное состояние.
Нюанс с SSR и CSR: в серверных методах (getServerSideProps
, getStaticProps
) отменять fetch не нужно — они выполняются до рендера HTML. А вот на клиенте без AbortController
вы рискуете получить утечки эффектов и гонки запросов, когда старый fetch вернётся позже нового и перетрёт актуальные данные.
Короче говоря, всегда оборачивайте fetch
в AbortController
и чистите эффект в useEffect
— это простая защита от неожиданных ошибок и дерганий UI.
Отображение состояний без лишней перерисовки
UI должен оставаться отзывчивым, без дерганий и «миганий» спиннера, если данные грузятся слишком быстро.
Переход через useTransition (React 18+)
import React, { useTransition } from 'react';
function SearchComponent() {
const [query, setQuery] = React.useState('');
const [startTransition, isPending] = useTransition();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const nextQuery = e.target.value;
setQuery(nextQuery);
startTransition(() => {
// тяжелая логика или новый запрос
fetchData(nextQuery);
});
};
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<Results query={query} />
</>
);
}
useTransition
откладывает менее приоритетные обновления (например, списки результатов), сохраняя интерактивность инпута.
Умный спиннер (отображать не сразу)
Не показывать индикатор при загрузке < 200 мс, чтобы избежать мерцания.
function SmartSpinner({ isLoading }: { isLoading: boolean }) {
const [show, setShow] = React.useState(false);
React.useEffect(() => {
let timer: NodeJS.Timeout;
if (isLoading) {
timer = setTimeout(() => setShow(true), 200);
} else {
setShow(false);
}
return () => clearTimeout(timer);
}, [isLoading]);
return show ? <Spinner /> : null;
}
Интеграция с запросами: react-query, SWR и кастомные хуки
Когда кастомный хук проще, чем query-библиотека
Если нужно лишь единичный fetch без кеширования и глобального рефетча — кастомный hook с reducer»ом займёт пару десятков строк и не привнесёт лишний «бандл».
Конфигурация повторных попыток, кешей, staleTime
React Query:
import { useQuery } from '@tanstack/react-query';
function useUser(id: string) {
return useQuery(['user', id], fetchUserById, {
staleTime: 1000 * 60 * 5, // 5 минут
retry: 2,
retryDelay: attempt => Math.min(1000 * 2 ** attempt, 30000),
onError: err => console.error('Ошибка при загрузке пользователя', err),
});
}
staleTime
предотвращает авто‑рефетч слишком часто. retry
и retryDelay
помогают бороться с временными ошибками сети.
SWR:
import useSWR from 'swr';
function usePosts() {
const { data, error, isLoading } = useSWR('/api/posts', fetcher, {
refreshInterval: 0,
revalidateOnFocus: true,
dedupingInterval: 2000,
onErrorRetry: (err, key, config, revalidate, { retryCount }) => {
if (retryCount >= 3) return;
setTimeout(() => revalidate({ retryCount: retryCount + 1 }), 2000);
},
});
return { data, error, isLoading };
}
Связь UI-состояния и сетевого слоя
React Query и SWR сами управляют статусом (isLoading
, isError
, data
). Вы лишь маппите эти булевы флаги на FSM‑состояния вашего UI:
function PostsList() {
const { data, isLoading, isError, refetch } = useQuery('posts', fetchPosts);
if (isLoading) return <SmartSpinner isLoading />;
if (isError) return (
<ErrorBlock onRetry={refetch}>
Ошибка при загрузке постов
</ErrorBlock>
);
return <Posts items={data} />;
}
При желании можно обатачить у react‑query свой редьюсер или паковать его статусы в единый объект { status, data, error }
, чтобы унифицировать работу с UI.
Вывод
Думаю, что добавление idle‑состояния — это не прихоть, а must‑have для надёжного UX, и стоит потратить пару часов на выстраивание FSM, чтобы потом не ловить баги.
Когда речь идет о надежности UI, важно учитывать не только стандартные состояния, но и предотвращение гонок запросов и оптимизацию перерисовок.
Рекомендую обратить внимание на открытый урок 19 мая в Otus, где будет разбираться, как оптимизировать загрузку файлов в React, используя File API. Вы узнаете, как обрабатывать PDF, Excel и изображения на клиенте без потери производительности.
Готовы проверить свои знания React? Пройдите короткое вступительное тестирование и узнайте, насколько уверенно вы себя чувствуете в теме.