React

TanStack Query: серверное состояние

Типичный React-проект 2019 года: компонент грузит список заказов, рядом другой компонент грузит того же пользователя, а третий показывает счётчик корзины. Каждый держит свои useState для data, loading и error, свой useEffect с fetch, свою обработку отмены запроса. Чтобы данные не разъезжались, всё это поднимают в Redux: actions, reducers, thunks, селекторы - двести строк инфраструктуры ради того, чтобы где-то показать число. В 2020 Таннер Линсли выпускает React Query, и оказывается, что серверные данные это вообще не то же самое, что состояние приложения, и работать с ними нужно иначе.

  • TanStack Query держит данные в дашбордах Vercel, Linear и десятках SaaS-панелей, где экраны построены вокруг запросов к API
  • Любой админ-интерфейс с таблицами, фильтрами и пагинацией: библиотека сама кэширует страницы и обновляет их в фоне
  • E-commerce: каталог, наличие на складе, цены - данные с сервера, которые устаревают и должны тихо обновляться
  • Команды массово удаляют Redux-слой загрузки данных, оставляя Redux или Zustand только под чисто клиентское состояние
  • GitHub, по докладам инженеров, использует похожий подход stale-while-revalidate для отзывчивости интерфейсов

Предварительные знания

  • Загрузка данных через fetch и обработка состояний loading/error
  • Хуки useState и useEffect и проблема гонок при ручном fetching
  • Базовое понимание промисов и async/await
  • Паттерны загрузки данных

Как серверное состояние выделили в отдельную категорию

Таннер Линсли заметил повторяющуюся ошибку: разработчики складывают ответы сервера в Redux, как будто это локальное состояние приложения. Но у серверных данных свои свойства - они принадлежат не клиенту, могут устареть в любой момент, их разделяют несколько компонентов и их нужно периодически сверять с источником. React Query (2020) предложил относиться к ним как к кэшу с понятием свежести, а не как к переменной. Позже проект стал фреймворк-независимым и переехал под зонтик TanStack вместе с Router, Table и Form. К 2026 это де-факто стандарт работы с серверными данными в React.

Почему серверное состояние это не обычный стейт

Состояние в React удобно делить на два вида. Клиентское состояние полностью принадлежит интерфейсу: открыта ли модалка, какая выбрана вкладка, что введено в поле до отправки. Серверное состояние это данные, которые живут в базе на сервере, а на клиенте присутствуют лишь как копия. Список заказов, профиль пользователя, остаток на складе - всё это где-то уже хранится, и клиент просто отображает снимок.

Эта разница важна на практике. Клиентское состояние всегда актуально по определению - оно и есть истина. Серверная копия устаревает: пока пользователь смотрел на список, другой человек добавил запись. Поэтому серверное состояние требует понятий, которых нет у обычной переменной - свежесть, фоновая сверка с источником, дедупликация одинаковых запросов, отмена и повторные попытки.

  • Клиентское состояние — Принадлежит интерфейсу, всегда актуально, синхронное. Примеры: открытая модалка, активная вкладка, черновик формы. Инструмент - useState, Zustand.
  • Серверное состояние — Принадлежит серверу, может устареть, асинхронное и разделяемое. Примеры: список заказов, профиль, цены. Инструмент - TanStack Query.

Главная мысль Линсли: складывать ответы сервера в Redux это попытка обращаться с кэшем как с переменной. Кэшу нужны срок годности и стратегия обновления, а не ручное копирование данных в стор и обратно.

Почему серверное состояние правильнее считать кэшем, а не обычной переменной приложения?

useQuery и useMutation

Ручная загрузка через useEffect требует трёх useState, флага отмены и аккуратной обработки гонок. useQuery сворачивает это в один вызов. Запросу даётся ключ (queryKey) и функция, возвращающая промис (queryFn). Библиотека сама дедуплицирует одинаковые ключи, кэширует результат, повторяет неудачные попытки и отдаёт готовые статусы.

Чтение делает useQuery, а запись - useMutation. Мутация не кэшируется по ключу: она выполняет действие (создать, обновить, удалить) и отдаёт статусы isPending, isError и колбэки onSuccess и onError. Само по себе изменение на сервере не обновляет кэш чтения - это задача инвалидации, к которой перейдём дальше.

queryKey это не просто строка, а сериализуемый массив. Ключ ['orders', { status: 'open' }] отличается от ['orders', { status: 'closed' }], и каждый кэшируется отдельно. Меняется ключ - меняется запрос, поэтому фильтры и id удобно класть прямо в ключ.

Чем роль useQuery отличается от роли useMutation?

Свежесть, инвалидация и оптимистичные обновления

Кэш живёт по двум таймерам. staleTime это срок, в течение которого данные считаются свежими: пока он не истёк, повторный useQuery с тем же ключом отдаёт кэш мгновенно и не идёт в сеть. После истечения данные становятся stale и при следующем обращении (фокус окна, новый монтаж компонента) тихо перезапрашиваются в фоне. gcTime это другое: сколько неиспользуемый кэш хранится в памяти после того, как последний компонент с этим запросом размонтирован, прежде чем сборщик мусора его удалит.

ПараметрЧто задаётПо умолчанию
staleTimeКак долго данные считаются свежими и не перезапрашиваются0 (сразу stale)
gcTimeСколько неиспользуемый кэш живёт в памяти до удаления5 минут
refetchOnWindowFocusПерезапрос при возврате фокуса на вкладкувключено
retryСколько раз повторять неудачный запрос3

Поведение stale-while-revalidate в том и состоит: пользователю мгновенно показывают кэш (stale), а в фоне сверяют его с сервером и при изменении подменяют. Интерфейс отзывчив и при этом не врёт долго. Когда мутация что-то меняет, связанный кэш помечают устаревшим вызовом invalidateQueries - это запускает фоновый refetch затронутых ключей.

Оптимистичное обновление применяет изменение в кэш сразу, до ответа сервера: в onMutate сохраняется прежнее значение и кэш правится локально. Если сервер ответил ошибкой, onError возвращает сохранённое значение - откат. В onSettled запускается инвалидация, чтобы кэш в любом случае сошёлся с сервером. Лайк или галочка ощущаются мгновенными, а корректность гарантирует финальная сверка.

Оптимистичные обновления уместны там, где успех почти гарантирован и важна мгновенная реакция: лайки, переключатели, добавление в список. Для платежей и других критичных операций честнее дождаться ответа сервера, а не показывать результат, который придётся откатывать.

Чем staleTime отличается от gcTime?

Связь с другими темами

Этот урок про серверное состояние. Дальше экосистема раскрывается по слоям:

  • Zustand — Чисто клиентское состояние (модалки, тема, фильтры до отправки) живёт в Zustand, а не в Query
  • TanStack Router — Загружает данные на уровне маршрута через loaders поверх того же кэша Query, убирая водопады запросов
  • Паттерны загрузки данных — Ручные подходы, которые Query автоматизирует: дедупликация, кэш, отмена, повторные попытки

Итог

  • Серверное состояние это отдельная категория: данные принадлежат серверу, устаревают и разделяются между компонентами, поэтому это кэш, а не переменная
  • useQuery декларативно читает данные по ключу: дедупликация, кэширование, повторные попытки и фоновое обновление идут из коробки
  • staleTime задаёт, как долго данные считаются свежими, gcTime - сколько неиспользуемый кэш живёт в памяти до сборки мусора
  • useMutation меняет данные на сервере, после чего invalidateQueries помечает связанный кэш устаревшим и запускает refetch
  • Оптимистичные обновления применяют изменение в кэш до ответа сервера и откатывают его при ошибке для мгновенной отзывчивости
  • Этот подход убирает ручной useEffect-fetching и обычно сотни строк Redux-инфраструктуры под загрузку данных

Связанные уроки

  • rc-28-data-fetching-patterns — TanStack Query это эволюция ручных паттернов загрузки данных, разобранных в этом уроке
  • rc-38-zustand-state — Серверное состояние держит TanStack Query, клиентское - Zustand. Вместе они закрывают почти весь стейт
  • rc-37-tanstack-router — Router вызывает запросы на уровне маршрута через loaders, опираясь на тот же кэш Query
TanStack Query: серверное состояние

0

1

Войти