Привет, Хабр!
При разработке фронтенда мы часто ограничиваемся тремя состояниями 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? Пройдите короткое вступительное тестирование и узнайте, насколько уверенно вы себя чувствуете в теме.
