Как стать автором
Обновить
564.34
OTUS
Цифровые навыки от ведущих экспертов

Idle, Loading, Error, Success: как устроить надёжный UI

Уровень сложностиСредний
Время на прочтение7 мин
Количество просмотров607

Привет, Хабр!

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

Теги:
Хабы:
+2
Комментарии0

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS