Извлечение данных без боли: как структурировать серверное состояние во фронтенд-приложениях

Современные веб-приложения всё чаще превращаются в сложные SPA с десятками экранов и сотнями API-вызовов. Типичная картина: компоненты верхнего уровня обрастают useEffect с повторяющимися запросами, приложение начинает получать одни и те же данные в разных местах, а состояние сервера смешивается с локальной бизнес-логикой. Результат — непредсказуемые ререндеры, конкурирующие запросы и лавинообразный рост сложности.

Главная недооценённая проблема здесь — отсутствие чёткой границы между клиентским и серверным состоянием. Файлы cookies — клиентское состояние, список пользователей из API — серверное. Первое можно изменять произвольно, второе требует синхронизации с источником истины.

Рассмотрим реалистичный пример с типичными ошибками:

typescript
function UserProfile() {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetch('/api/user')
      .then(res => res.json())
      .then(setUser);
  }, []);

  useEffect(() => {
    fetch('/api/posts?userId=123')
      .then(res => res.json())
      .then(setPosts);
  }, []);

  // Рендер пользователя и постов
}

Кажется безобидным? Но здесь:

  • Нет обработки ошибок
  • Нет инвалидации устаревших данных
  • Неизбежны дублирующиеся запросы при нескольких экземплярах компонента
  • Сложность с префетчингом данных

Решение — выделение слоя серверного состояния с помощью специализированных библиотек. React Query и Apollo Client дают три ключевых преимущества:

  1. Декларативное описание данных
  2. Автоматическое кэширование
  3. Фоновое обновление по стратегиям

Перепишем пример с React Query:

typescript
const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <UserProfile />
    </QueryClientProvider>
  );
}

function UserProfile() {
  const { data: user } = useQuery(['user'], () => 
    fetch('/api/user').then(res => res.json())
  );
  
  const { data: posts } = useQuery(['posts', user?.id], () =>
    fetch(`/api/posts?userId=${user.id}`).then(res => res.json()),
    { enabled: !!user?.id }
  );

  // Бонус: префетчинг при наведении на кнопку
  const prefetchPosts = () => 
    queryClient.prefetchQuery(['posts', user.id], fetchPosts);
}

Что изменилось:

  • Кэш живет вне компонентов, управляется уникальными ключами
  • Запросы автоматически дедуплицируются
  • Зависимые запросы (posts после user) реализуются через enabled
  • Появилась возможность префетчинга

Структура кэша — важнейший архитектурный аспект. Ключи должны отражать сущность данных, а не их местоположение в коде. Хороший паттерн — соглашение об именовании:

  • ['user', id] для конкретного пользователя
  • ['posts', { userId, page }] для пагинированных данных
  • ['config', 'theme'] для глобальных настроек

Рекомендации по работе с RQ в продакшене:

  1. Инвалидация после мутаций: после изменения данных через POST/PUT вызывать queryClient.invalidateQueries(['posts']) для фонового обновления
  2. Оптимистичные обновления: при удалении элемента обновлять кэш локально до ответа сервера
  3. Группировка ошибок: перехватывать ошибки HTTP на уровне клиента, а не в каждом запросе
  4. Пагинация и бесконечная лента: использовать useInfiniteQuery с обработкой страниц как связанного списка

Типичная ошибка новичков — хранить серверный и клиентский state в одном месте. Контрпример:

typescript
// Антипаттерн!
const [user, setUser] = useState(initialUser);
const { data } = useQuery(['user'], fetchUser);

// Путаница: user может быть из кэша или из локального состояния

Правильный подход — разделение:

  • Данные из API — только через Query Client
  • Локальные изменения — через useState или Formik
  • Синхронизация через onSuccess в мутациях

Производительность приложения напрямую зависит от стратегий обновления данных. Сравним варианты:

  • Stale-While-Revalidate (по умолчанию): мгновенная отрисовка кэша с фоновым запросом
  • Строгая актуальность: staleTime: 0 — данные всегда свежие, ценой задержек
  • Оффлайн-режим: cacheTime: Infinity для сохранения данных между сессиями

При миграции с legacy-подхода важны два шага:

  1. Постепенная замена глобального состояния (Redux) на кэш-менеджер
  2. Анализ повторяющихся запросов через DevTools (в RQ есть встроенная панель)

Вывод не как шаблонная фраза, а как инженерное правило: серверное состояние должно управляться как внешняя синхронизируемая база данных. Его жизненный цикл (запрос, кэширование, обновление) нужно инкапсулировать, а не раскидывать эффектами по компонентам. Для большинства приложений React Query становится оптимальным выбором, заменяя 80% кода, связанного с данными, при сохранении полного контроля над edge-кейсами.

text