Загрузка данных и loading states

Loading states — явные состояния UI (загрузка, успех, ошибка, пусто), которые компонент отображает во время асинхронных операций; правильное управление ими — обязательная часть UX.

Зачем нужно

Без loading states пользователь видит пустой экран или устаревшие данные пока запрос выполняется. Правильно спроектированные loading states (скелетоны, спиннеры, индикаторы прогресса) делают приложение отзывчивым и понятным. Типичная ошибка начинающих — забыть состояние ошибки, и пользователь видит пустой список без объяснений.

Где используется

  • Любой компонент с асинхронной загрузкой данных (списки, детали, дашборды)
  • Формы: состояние отправки (isSubmitting) и успеха/ошибки
  • Infinite scroll и пагинация
  • Загрузка файлов с прогрессом

Паттерн: состояния загрузки

function UserList() {
  const [state, setState] = useState({
    data: null,
    isLoading: true,
    error: null,
  });

  useEffect(() => {
    setState({ data: null, isLoading: true, error: null });

    fetch('/api/users')
      .then(r => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .then(data => setState({ data, isLoading: false, error: null }))
      .catch(error => setState({ data: null, isLoading: false, error }));
  }, );

  // Явная обработка каждого состояния
  if (state.isLoading) {
    return <UserListSkeleton />;  // скелетон лучше спиннера
  }

  if (state.error) {
    return (
      <ErrorMessage
        message={`Не удалось загрузить пользователей: ${state.error.message}`}
        onRetry={ => setState(prev => ({ ...prev, isLoading: true }))}
      />
    );
  }

  if (!state.data || state.data.length === 0) {
    return <EmptyState message="Пользователей не найдено" />;
  }

  return (
    <ul>
      {state.data.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Кастомный хук для загрузки данных

function useFetch(url) {
  const [state, setState] = useState({
    data: null,
    isLoading: true,
    error: null,
  });

  useEffect(() => {
    const controller = new AbortController();
    setState({ data: null, isLoading: true, error: null });

    fetch(url, { signal: controller.signal })
      .then(r => r.json())
      .then(data => setState({ data, isLoading: false, error: null }))
      .catch(err => {
        if (err.name !== 'AbortError') {
          setState({ data: null, isLoading: false, error: err });
        }
      });

    return  => controller.abort();
  }, [url]);

  return state;
}

// Использование
function ProductPage({ id }) {
  const { data: product, isLoading, error } = useFetch(`/api/products/${id}`);

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorMessage error={error} />;
  return <ProductDetails product={product} />;
}

Скелетон вместо спиннера

// Скелетон — лучший UX: показывает структуру страницы
function UserCardSkeleton() {
  return (
    <div className="skeleton-card">
      <div className="skeleton-avatar" />
      <div className="skeleton-line skeleton-line--wide" />
      <div className="skeleton-line skeleton-line--medium" />
    </div>
  );
}

function UserListSkeleton() {
  return (
    <ul>
      {Array.from({ length: 5 }).map((_, i) => (
        <li key={i}><UserCardSkeleton /></li>
      ))}
    </ul>
  );
}

Частые ошибки

  • Только два состояния (loading/ready) — забывают об ошибках и пустых данных; пользователь видит пустой список без объяснений.
  • Не сбрасывают isLoading перед новым запросом — при смене пропсов (url) показывают старые данные пока грузятся новые.
  • Нет AbortController — при быстрой навигации старый запрос возвращается позже нового и перезаписывает актуальные данные (race condition).
  • Спиннер для долгих операций — скелетоны снижают воспринимаемое время ожидания лучше, чем вращающийся индикатор.

Связанные темы

Ресурсы