Работа с API в SPA

В SPA взаимодействие с сервером происходит через HTTP-запросы из JavaScript — Fetch API или axios — без перезагрузки страницы; сервер возвращает данные (обычно JSON), а не новый HTML.

Зачем нужно

В отличие от MPA, где сервер формирует HTML-страницы, SPA общается с сервером через REST или GraphQL API. Знание паттернов работы с API — обязательная компетенция: правильная обработка ошибок, отмена запросов, аутентификация через токены, централизованный API-клиент. Без этого код быстро превращается в хаос из разрозненных fetch вызовов.

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

  • Fetch API — нативный браузерный инструмент, без зависимостей
  • Axios — популярная библиотека с interceptors, автоматическим JSON, отменой
  • React Query / SWR — для декларативной загрузки с кешированием
  • GraphQL клиенты (Apollo) — типизированные запросы к GraphQL API

Fetch API: основные паттерны

// GET запрос с обработкой ошибок
async function fetchUser(userId) {
  const response = await fetch(`/api/users/${userId}`, {
    headers: {
      'Authorization': `Bearer ${getToken}`,
      'Content-Type': 'application/json',
    },
  });

  // fetch не выбрасывает ошибку для HTTP 4xx/5xx — проверяем вручную
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }

  return response.json();
}

// POST запрос с телом
async function createPost(data) {
  const response = await fetch('/api/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message);
  }

  return response.json();
}

// Отмена запроса через AbortController
async function fetchWithAbort(url, signal) {
  const response = await fetch(url, { signal });
  return response.json();
}

// Использование в компоненте
useEffect(() => {
  const controller = new AbortController();

  fetchWithAbort(`/api/users/${id}`, controller.signal)
    .then(setUser)
    .catch(err => {
      if (err.name !== 'AbortError') setError(err);
    });

  return  => controller.abort(); // cleanup при смене id или размонтировании
}, [id]);

Централизованный API-клиент

// api/client.js — единая точка для всех запросов
const BASE_URL = process.env.REACT_APP_API_URL;

async function apiRequest(endpoint, options = {}) {
  const token = localStorage.getItem('token');

  const config = {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(token && { Authorization: `Bearer ${token}` }),
      ...options.headers,
    },
  };

  const response = await fetch(`${BASE_URL}${endpoint}`, config);

  if (response.status === 401) {
    // Токен истёк — редирект на логин
    localStorage.removeItem('token');
    window.location.href = '/login';
    return;
  }

  if (!response.ok) {
    const data = await response.json().catch( => ({}));
    throw new Error(data.message || `HTTP ${response.status}`);
  }

  // 204 No Content — нет тела ответа
  if (response.status === 204) return null;

  return response.json();
}

// Удобные методы
export const api = {
  get: (url) => apiRequest(url),
  post: (url, data) => apiRequest(url, { method: 'POST', body: JSON.stringify(data) }),
  put: (url, data) => apiRequest(url, { method: 'PUT', body: JSON.stringify(data) }),
  delete: (url) => apiRequest(url, { method: 'DELETE' }),
};

// Использование
const user = await api.get('/users/42');
const newPost = await api.post('/posts', { title: 'Привет', content: '...' });

Axios: interceptors

import axios from 'axios';

const apiClient = axios.create({
  baseURL: process.env.REACT_APP_API_URL,
  timeout: 10000,
});

// Request interceptor — добавляет токен к каждому запросу
apiClient.interceptors.request.use(config => {
  const token = localStorage.getItem('token');
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

// Response interceptor — обработка ошибок
apiClient.interceptors.response.use(
  response => response.data, // автоматически извлекаем data
  error => {
    if (error.response?.status === 401) {
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

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

  • Не проверяют response.okfetch не выбрасывает ошибку для 404/500; response.json может вернуть объект ошибки, который выглядит как успех.
  • Нет AbortController — при быстрой навигации старые запросы обновляют state размонтированного компонента.
  • Прямые fetch-вызовы в компонентах — без централизованного клиента обработка токенов и ошибок дублируется в каждом месте.

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

Ресурсы