Урок 28: Работа с внешними API (обработка состояний загрузки и ошибок)

Если предыдущие уроки были о том, как заставить интерфейс работать, то сегодня мы поговорим о том, как заставить его работать хорошо.

Представь. Пользователь нажимает кнопку «Загрузить», и… ничего не происходит. Проходит секунда, две, пять. Что он думает? «Сломалось?», «Не нажал?», «Виснет?». Скорее всего, он просто закроет вкладку. Наша задача не допустить этого. Мы будем делать наши приложения отзывчивыми, честными и предсказуемыми.

Сегодня, на уроке 28, мы изучим улучшения пользовательского опыта (UX) при работе с внешними API. Мы разберём паттерны для обработки трёх ключевых состояний любого асинхронного запроса: loading (загрузка), error (ошибка) и success (успех).

Управление состоянием запроса

В идеальном мире интернет всегда быстрый, API никогда не падают, а данные приходят мгновенно. Но мы живём в реальности, где всё это несбыточная мечта. Асинхронные операции (как раз те самые запросы к API) по своей природе непредсказуемы. Они занимают время, и в процессе этого времени с ними может что-то случиться.

Если мы не сообщим пользователю, что происходит, он будет чувствовать потерю контроля и дезориентацию. Управление состояниями loadingerror и success это наш способ коммуникации с пользователем. Это наш диалог с ним: «Подожди, я работаю», «Упс, что-то пошло не так, но вот что именно…» или «Всё готово, вот твои данные!».

Без этой коммуникации наше приложение кажется «тупым» и ненадёжным. С ней оно становится профессиональным, продуманным и удобным. Это тонкая, но очень важная грань между любительским и качественным продуктом. В современной веб-разработке это не просто «фишка», а стандарт, которого ждут пользователи.

Базовый паттерн: три состояния — один компонент

Давай начнём с фундаментального паттерна, который лежит в основе всего, о чём мы будем говорить сегодня. Мы будем явно отслеживать три состояния в нашем компоненте: isLoading (идёт загрузка), error (объект ошибки, если она есть) и data (успешно полученные данные).

Раньше мы могли писать просто const [data, setData] = useState(null). Теперь наш стейт станет более сложным и информативным. Это основа, на которую мы будем нанизывать все последующие улучшения.

jsx
import { useState } from 'react';

function UserProfile({ userId }) {
  // Теперь мы управляем тремя сущностями:
  const [userData, setUserData] = useState(null);  // Данные
  const [isLoading, setIsLoading] = useState(false); // Флаг загрузки
  const [error, setError] = useState(null);        // Объект ошибки

  const fetchUser = async () => {
    // Перед началом запроса обнуляем ошибку и включаем загрузку
    setIsLoading(true);
    setError(null);

    try {
      const response = await fetch(`https://api.example.com/users/${userId}`);
      if (!response.ok) {
        // Если HTTP-статус не в диапазоне 200-299, выбрасываем ошибку
        throw new Error(`Ошибка! Статус: ${response.status}`);
      }
      const data = await response.json();
      setUserData(data); // Успех! Сохраняем данные
    } catch (err) {
      setError(err.message); // Перехватили ошибку, сохраняем её
    } finally {
      setIsLoading(false); // В любом случае выключаем загрузку
    }
  };

  // Рендерим интерфейс в зависимости от состояния
  return (
    <div>
      <button onClick={fetchUser} disabled={isLoading}>
        {isLoading ? 'Загрузка...' : 'Загрузить профиль'}
      </button>

      {isLoading && <p>Загружаем данные пользователя...</p>}

      {error && (
        <div style={{ color: 'red' }}>
          <p>Произошла ошибка:</p>
          <p>{error}</p>
          <button onClick={() => setError(null)}>Скрыть</button>
        </div>
      )}

      {userData && !isLoading && (
        <div>
          <h2>{userData.name}</h2>
          <p>Email: {userData.email}</p>
        </div>
      )}
    </div>
  );
}

export default UserProfile;

Обрати внимание на ключевые моменты:

  1. Последовательность действий. setLoading(true) и setError(null) в начале, что гарантирует сброс предыдущих ошибок и отображение индикатора.

  2. try-catch-finally. Идеальная конструкция для асинхронных запросов. В finally мы всегда выключаем загрузку, независимо от успеха или провала.

  3. Условный рендеринг. Мы рендерим разные части UI в зависимости от комбинации состояний isLoadingerror и userData.

Углубляемся в состояние загрузки (Loading)

Состояние загрузки, по факту это спиннер. Это мощный инструмент управления вниманием пользователя и предотвращения нежелательных действий.

Разновидности индикаторов загрузки

Есть несколько способов показать, что идёт работа. Выбор зависит от контекста:

  1. Кнопка с изменённым состоянием. Самый простой способ. Мы меняем текст кнопки на «Загрузка…» и отключаем её (disabled={isLoading}). Это предотвращает повторные нажатия и показывает, что действие началось.

  2. Инлайн-спиннер/текст. Небольшой индикатор рядом с элементом, который загружается. Идеально для действий, которые не блокируют весь интерфейс (например, добавление товара в корзину).

  3. Скелетон-экраны. Современный и психологически более приятный способ. Вместо спиннера мы показываем «заглушку», схематичный макет будущего контента с анимированным градиентом. Это создаёт ощущение, что контент уже готовится к показу и снижает субъективное время ожидания.

  4. Прогресс-бар. Лучший вариант для операций, время выполнения которых можно оценить (например, загрузка файла). В мире API это редкость, но знать о нём полезно.

Давай улучшим наш пример, добавив скелетон для загрузки профиля пользователя.

jsx
// Компонент-скелетон для карточки пользователя
const UserProfileSkeleton = () => (
  <div style={{ border: '1px solid #ccc', padding: '10px', width: '200px' }}>
    <div
      style={{
        height: '20px',
        background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
        backgroundSize: '200% 100%',
        animation: 'loading 1.5s infinite',
        marginBottom: '10px'
      }}
    ></div>
    <div
      style={{
        height: '16px',
        background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
        backgroundSize: '200% 100%',
        animation: 'loading 1.5s infinite',
      }}
    ></div>
  </div>
);

// Добавляем CSS-анимацию (можно в отдельный файл)
const skeletonStyles = `
@keyframes loading {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
`;

function UserProfile({ userId }) {
  // ... тот же стейт, что и раньше ...

  return (
    <div>
      <style>{skeletonStyles}</style>
      <button onClick={fetchUser} disabled={isLoading}>
        {isLoading ? 'Загрузка...' : 'Загрузить профиль'}
      </button>

      {/* Вместо текста показываем скелетон */}
      {isLoading && <UserProfileSkeleton />}

      {error && ( ... )} {/* Ошибка остаётся без изменений */}

      {userData && !isLoading && ( ... )} {/* Успех остаётся без изменений */}
    </div>
  );
}

Практическая задача 1: Создай компонент DataTable, который при монтировании загружает список постов с https://jsonplaceholder.typicode.com/posts. Пока данные грузятся, покажи скелетон-таблицу с 5 строками. Реализуй кнопку «Обновить», которая повторяет запрос и тоже показывает состояние загрузки.

Детально прорабатываем состояние ошибки (Error)

Ошибки это не неудача, это часть пользовательского пути. Наша задача обработать их так, чтобы пользователь не испугался и понял, что делать дальше.

Типы ошибок и стратегии их обработки

Не все ошибки одинаковы. Давай классифицируем их:

  1. Ошибки клиента (4xx). Например, 400 Bad Request (неправильный запрос), 401 Unauthorized (не авторизован), 404 Not Found (данные не найдены), 429 Too Many Requests (слишком много запросов).

  2. Ошибки сервера (5xx). 500 Internal Server Error502 Bad Gateway. Это проблемы на стороне бэкенда.

  3. Сетевые ошибки. Пропал интернет, не удалось resolve доменное имя.

  4. Ошибки парсинга JSON и другие исключения в коде.

Для каждого типа можно предусмотреть свою логику. Например, для 401 перенаправить на страницу логина, для 404 показать дружелюбное сообщение «Ничего не найдено», для 429 показать сообщение «Слишком много запросов, попробуйте через минуту».

Давай модернизируем наш компонент обработки ошибок.

jsx
function UserProfile({ userId }) {
  const [userData, setUserData] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  // Функция для классификации ошибок
  const getErrorMessage = (err) => {
    if (err.message.includes('404')) {
      return 'Пользователь с таким ID не найден.';
    } else if (err.message.includes('500')) {
      return 'Проблема на сервере, попробуйте позже.';
    } else if (err.message.includes('Failed to fetch')) {
      return 'Проблема с интернет-соединением. Проверьте сеть.';
    } else {
      return `Что-то пошло не так: ${err.message}`;
    }
  };

  const fetchUser = async () => {
    setIsLoading(true);
    setError(null);

    try {
      const response = await fetch(`https://api.example.com/users/${userId}`);
      if (!response.ok) {
        throw new Error(`HTTP Error: ${response.status}`);
      }
      const data = await response.json();
      setUserData(data);
    } catch (err) {
      // Используем нашу функцию для получения понятного сообщения
      const userFriendlyError = getErrorMessage(err);
      setError(userFriendlyError);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div>
      {/* ... кнопка и скелетон ... */}

      {error && (
        <div style={{
          padding: '10px',
          margin: '10px 0',
          backgroundColor: '#ffebee',
          border: '1px solid #f44336',
          borderRadius: '4px',
          color: '#c62828'
        }}>
          <p><strong>Внимание!</strong></p>
          <p>{error}</p>
          {/* Предлагаем действия */}
          <button onClick={fetchUser}>Попробовать снова</button>
          <button onClick={() => setError(null)} style={{ marginLeft: '10px' }}>
            Скрыть
          </button>
        </div>
      )}

      {/* ... успешный рендеринг ... */}
    </div>
  );
}

Практическая задача 2: Возьми компонент из Задачи 1. Добавь обработку ошибок. Если приходит ошибка 404, покажи сообщение «Посты не найдены». Если приходит любая другая ошибка, покажи сообщение «Не удалось загрузить данные» и кнопку «Повторить запрос». Сымитируй ошибку, используя невалидный URL.

Паттерн «State as a Snapshot»

Одна из самых частых ошибок начинающих React-разработчиков при работе с асинхронностью, это «stale state» (устаревший стейт). Давай рассмотрим проблемный код:

jsx
// ПЛОХО! Этот код может вести себя непредсказуемо.
const handleClick = () => {
  setIsLoading(true);
  fetchData().then((data) => {
    setIsLoading(false);
    setData(data); // Представь, что пока шёл запрос, `isLoading` уже стал false другим образом?
  });
};

Проблема в том, что состояние в React, это снимок (snapshot). Когда ты создаёшь функцию (например, внутри then или catch), она «замыкается» на значения переменных на момент своего создания. Если за время запроса состояние isLoading изменится (например, другим действием пользователя), это может привести к конфликтам.

Решение, использовать функции обновления состояния, которые получают актуальное состояние.

jsx
// ХОРОШО! Используем функцию для актуального значения.
const [count, setCount] = useState(0);

const increment = () => {
  // setCount(count + 1); // Может быть неактуально, если несколько обновлений в очереди
  setCount(prevCount => prevCount + 1); // Всегда берём актуальное предыдущее значение
};

Применим это к нашему асинхронному запросу. Хотя в нашем простом примере с try-catch это не было критично, в более сложных сценариях (например, с использованием then/catch) это становится важным.

jsx
// Более надёжный вариант с useRef для отслеживания актуальности запроса
function UserProfile({ userId }) {
  const [userData, setUserData] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  // Используем ref для отслеживания, не был ли компонент размонтирован
  const abortControllerRef = useRef(null);

  const fetchUser = async () => {
    // Если есть предыдущий запрос, отменяем его
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }
    // Создаём новый AbortController для текущего запроса
    abortControllerRef.current = new AbortController();

    setIsLoading(true);
    setError(null);

    try {
      const response = await fetch(`https://api.example.com/users/${userId}`, {
        signal: abortControllerRef.current.signal // Передаём сигнал для отмены
      });
      if (!response.ok) {
        throw new Error(`HTTP Error: ${response.status}`);
      }
      const data = await response.json();
      // Перед установкой данных проверяем, не отменён ли запрос
      if (!abortControllerRef.current.signal.aborted) {
        setUserData(data);
      }
    } catch (err) {
      // Игнорируем ошибку, если запрос был отменён намеренно
      if (err.name !== 'AbortError') {
        setError(getErrorMessage(err));
      }
    } finally {
      if (!abortControllerRef.current.signal.aborted) {
        setIsLoading(false);
      }
    }
  };

  // При размонтировании компонента отменяем запрос
  useEffect(() => {
    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, []);

  // ... остальной рендеринг ...
}

Это продвинутая, но очень важная практика. Она предотвращает утечки памяти и попытки обновления состояния в уже размонтированном компоненте.

Композитное состояние

Хранить три отдельных кусочка состояния (isLoadingerrordata) это нормально для начала. Но что, если у нас несколько запросов? Или более сложная логика? Можно прийти к противоречивым состояниям, например, isLoading: true и error: 'Something wrong' одновременно.

Один из элегантных паттернов, использовать перечисление (enum) для статуса запроса. Это делает состояние атомарным и исключает невозможные комбинации.

jsx
// Определяем возможные статусы запроса
const REQUEST_STATUS = {
  IDLE: 'idle',      // Ничего не происходит
  PENDING: 'pending', // Запрос в процессе
  RESOLVED: 'resolved', // Запрос успешно завершён
  REJECTED: 'rejected'  // Запрос завершился ошибкой
};

function UserProfile({ userId }) {
  // Теперь у нас одно состояние "status" и два для данных/ошибки
  const [status, setStatus] = useState(REQUEST_STATUS.IDLE);
  const [userData, setUserData] = useState(null);
  const [error, setError] = useState(null);

  const fetchUser = async () => {
    setStatus(REQUEST_STATUS.PENDING);
    setError(null);

    try {
      const response = await fetch(`https://api.example.com/users/${userId}`);
      if (!response.ok) {
        throw new Error(`Ошибка! Статус: ${response.status}`);
      }
      const data = await response.json();
      setUserData(data);
      setStatus(REQUEST_STATUS.RESOLVED); // Успех!
    } catch (err) {
      setError(err.message);
      setStatus(REQUEST_STATUS.REJECTED); // Ошибка!
    }
  };

  // Рендерим становится очень чистым и логичным
  return (
    <div>
      <button
        onClick={fetchUser}
        disabled={status === REQUEST_STATUS.PENDING}
      >
        {status === REQUEST_STATUS.PENDING ? 'Загрузка...' : 'Загрузить профиль'}
      </button>

      {status === REQUEST_STATUS.PENDING && <UserProfileSkeleton />}

      {status === REQUEST_STATUS.REJECTED && (
        <div style={{ color: 'red' }}>
          <p>Ошибка: {error}</p>
          <button onClick={fetchUser}>Повторить</button>
        </div>
      )}

      {status === REQUEST_STATUS.RESOLVED && (
        <div>
          <h2>{userData.name}</h2>
          <p>Email: {userData.email}</p>
        </div>
      )}
    </div>
  );
}

Этот подход масштабируется гораздо лучше. Добавить новое состояние (например, «обновление») становится проще и логика рендеринга остаётся чистой.

Продвинутые паттерны: кастомные хуки и Context

Когда твоё приложение растёт, обрабатывать состояния загрузки и ошибок в каждом компоненте становится утомительно и приводит к дублированию кода. Здесь на помощь приходят Кастомные хуки.

Давай вынесем всю логику запроса в отдельный хук useApi.

jsx
// hooks/useApi.js
import { useState, useEffect } from 'react';

const useApi = (url) => {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Функция для выполнения запроса
    const fetchData = async () => {
      if (!url) return; // Не делаем запрос, если URL нет

      setIsLoading(true);
      setError(null);

      try {
        const response = await fetch(url);
        if (!response.ok) {
          throw new Error(`Ошибка! Статус: ${response.status}`);
        }
        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err.message);
      } finally {
        setIsLoading(false);
      }
    };

    fetchData();
  }, [url]); // Запрос выполнится при изменении `url`

  // Возвращаем состояние, чтобы компонент мог его использовать
  return { data, isLoading, error };
};

export default useApi;

Теперь наш компонент UserProfile становится невероятно простым!

jsx
// components/UserProfile.js
import useApi from '../hooks/useApi';
import UserProfileSkeleton from './UserProfileSkeleton';

function UserProfile({ userId }) {
  // Всю логику взял на себя хук useApi!
  const { data: userData, isLoading, error } = useApi(
    userId ? `https://api.example.com/users/${userId}` : null
  );

  // Рендерим состояния на основе данных из хука
  return (
    <div>
      {isLoading && <UserProfileSkeleton />}
      {error && <div>Ошибка: {error}</div>}
      {userData && (
        <div>
          <h2>{userData.name}</h2>
          <p>Email: {userData.email}</p>
        </div>
      )}
    </div>
  );
}

export default UserProfile;

Красота, не правда ли? Хук useApi можно переиспользовать в десятках компонентов. Его можно улучшать бесконечно: добавить возможность перезапуска запроса, отмены, кэширования и т.д.

Практическая задача 3: Перепиши компонент DataTable из Задач 1 и 2, используя кастомный хук useApi. Убедись, что скелетон и обработка ошибок работают корректно.

Глобальное управление состоянием загрузки и ошибок

Бывают ситуации, когда тебе нужно показать глобальный индикатор загрузки (например, в шапке приложения) при любом активном запросе. Или выводить «тосты» (всплывающие уведомления) об ошибках, которые видны поверх всего интерфейса.

Для этого идеально подходит связка Context + Кастомный Хук.

  1. Создаём Context для уведомлений:

jsx
// context/NotificationContext.js
import React, { createContext, useState, useContext } from 'react';

const NotificationContext = createContext();

export const NotificationProvider = ({ children }) => {
  const [notification, setNotification] = useState({ message: '', type: '' }); // type: 'error', 'success', 'loading'

  const showNotification = (message, type = 'error') => {
    setNotification({ message, type });
    // Автоматически скрываем уведомление через 5 секунд
    if (type !== 'loading') {
      setTimeout(() => {
        hideNotification();
      }, 5000);
    }
  };

  const hideNotification = () => {
    setNotification({ message: '', type: '' });
  };

  return (
    <NotificationContext.Provider value={{ notification, showNotification, hideNotification }}>
      {children}
      {/* Глобальный тост */}
      {notification.message && (
        <div style={{
          position: 'fixed',
          top: '20px',
          right: '20px',
          padding: '15px',
          backgroundColor: notification.type === 'error' ? '#f44336' : '#4caf50',
          color: 'white',
          borderRadius: '4px',
          zIndex: 1000
        }}>
          {notification.message}
          <button onClick={hideNotification} style={{ marginLeft: '10px', color: 'white' }}>X</button>
        </div>
      )}
    </NotificationContext.Provider>
  );
};

// Кастомный хук для удобного использования
export const useNotification = () => {
  const context = useContext(NotificationContext);
  if (!context) {
    throw new Error('useNotification must be used within a NotificationProvider');
  }
  return context;
};
  1. Оборачиваем приложение в Provider:

jsx
// index.js или App.js
import { NotificationProvider } from './context/NotificationContext';

ReactDOM.render(
  <NotificationProvider>
    <App />
  </NotificationProvider>,
  document.getElementById('root')
);
  1. Используем в нашем кастомном хуке useApi:

jsx
// hooks/useApi.js (улучшенная версия)
import { useNotification } from '../context/NotificationContext';

const useApi = (url) => {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  const { showNotification, hideNotification } = useNotification(); // Используем контекст

  useEffect(() => {
    const fetchData = async () => {
      if (!url) return;

      setIsLoading(true);
      setError(null);
      showNotification('Загружаем данные...', 'loading'); // Показываем глобальную загрузку

      try {
        const response = await fetch(url);
        if (!response.ok) {
          throw new Error(`Ошибка! Статус: ${response.status}`);
        }
        const result = await response.json();
        setData(result);
        showNotification('Данные успешно загружены!', 'success'); // Успех!
      } catch (err) {
        setError(err.message);
        showNotification(`Ошибка загрузки: ${err.message}`, 'error'); // Ошибка!
      } finally {
        setIsLoading(false);
        // Убираем глобальную загрузку (если бы она была отдельным состоянием в контексте)
        // hideNotification(); // Но здесь мы скроем её автоматически через timeout в success/error
      }
    };

    fetchData();
  }, [url, showNotification, hideNotification]);

  return { data, isLoading, error };
};

Теперь любая ошибка или успешная загрузка через хук useApi будет показывать красивое глобальное уведомление! Это мощный и масштабируемый паттерн.

Итог урока

Сегодня мы прошли огромный путь, от простого текста «Загрузка…» до создания переиспользуемой системы управления состояниями асинхронных операций.

Ключевые выводы:

  1. Всегда информируй пользователя. Состояния loadingerror и success это обязательные элементы современного UI.

  2. Используй правильные индикаторы. Скелетоны лучше спиннеров для загрузки контента, кнопки должны блокироваться.

  3. Обрабатывай ошибки детально. Пользователь должен понять, что случилось и что делать дальше.

  4. Помни про «Stale State» и «Race Conditions». Используй функции обновления и AbortController для надёжности.

  5. Декомпозируй и переиспользуй. Выноси логику запросов в кастомные хуки. Для глобальных уведомлений используй Context.

Этот урок один из тех, который сразу выводит твои приложения на новый уровень качества. Увидимся на следующем уроке.

Хочешь освоить React на уверенном уровне? Переходи к другим урокам в моём полном курсе для начинающих. Там мы разбираем всё, от основ JSX до сложных паттернов и лучших практик.

Полный курс с уроками по React для начинающих

Поделиться статьей:
Поддержать автора блога

Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.

Персональные рекомендации
Оставить комментарий