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