Query-библиотеки: идентичность queryKey, два таймера и что TanStack Query реально экономит
TanStack Query относится к серверным данным как к кешу, а не состоянию: queryKey — идентичность кеша (дедупликация), staleTime и gcTime — два независимых таймера (перезапрос и память), refetch по фокусу держит данные живыми, а мутации с invalidateQueries замыкают цикл записи.
Первой эскалировала команда бэкенда: «/api/me — это 30% нашего трафика. Почему ваша SPA зовёт его шесть раз на навигацию?» Фронтенд-аудит был унизителен знакомым образом. Шапка запрашивала текущего пользователя ради аватара. Сайдбар — ради прав. Три виджета — ради персонализации. У каждого компонента — свой прилежный useEffect, свой флаг loading, своя копия ответа в своём useState: шесть запросов на один ресурс, ни один не разделён, ни один не обновляется после маунта. Пользователь, сменивший имя в настройках, видел старое имя в шапке до нажатия F5. Первым порывом команды был самописный контекст-плюс-кеш; через два спринта в нём были утечка памяти, отсутствие ретраев и гонка, которой не было даже у исходных эффектов. То, что они плохо пересобирали, — это кеш серверного состояния: дедупликация запросов, учёт устаревания, фоновый перезапрос, сборка мусора. Вот настоящий продукт TanStack Query и его собратьев: не «удобный fetch», а признание, что серверные данные — это кеш с кешевыми проблемами, у которых сорок лет известных решений.
queryKey — идентичность кеша, а не подпись
Сдвиг в голове: серверные данные — не ваше состояние, а кеш чужого состояния. TanStack Query делает это явным. Каждый запрос идентифицируется своим queryKey — массивом, хешируемым структурно (порядок ключей объекта не важен, порядок массива важен): ['todos', { status, page }]. Ключ — это адрес в кеше. Каждый компонент, зовущий useQuery с тем же ключом, читает ту же запись кеша — шесть компонентов, просящих ['me'], дают один запрос, дедуплицированный в полёте, и один общий ответ. Отсюда дисциплина: каждая переменная, которую читает queryFn, обязана быть в ключе — ровно как массив зависимостей useEffect. Забудьте page в ключе — и страница 2 перезапишет страницу 1 по тому же адресу; симптом — «мелькают чужие данные», диагноз почти всегда — ключ, недоописывающий запрос.
function Todos({ status, page }) {
const { data, isPending, isError, error } = useQuery({
queryKey: ["todos", { status, page }], // идентичность = все входы
queryFn: ({ signal }) =>
fetchTodos({ status, page, signal }), // abort подключён бесплатно
staleTime: 30_000, // свежо 30 секунд
});
// isPending → первая загрузка, кеша ещё нет
// data может быть и показана, и устаревшей: отдана мгновенно, перезапрошена сзади
}
function AddTodo() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createTodo,
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["todos"] }), // по префиксу
});
return <button onClick={() => mutation.mutate({ title: "ship" })}>Add</button>;
}Два таймера: staleTime решает перезапрос, gcTime решает память
Каждой записью кеша управляют два независимых таймера, и их смешивание — недоразумение номер один вокруг TanStack Query. staleTime отвечает: «достаточно ли эти данные свежи, чтобы отдать их, не спрашивая сервер снова?» Пока запись свежа, любой маунт или фокус окна отдаёт её из кеша, точка — ноль запросов. Когда запись устарела, данные всё равно отдаются мгновенно, но фоновый перезапрос стреляет на следующем триггере: монтируется новый компонент, окно получает фокус, сеть переподключается. Это stale-while-revalidate: пользователь всегда видит данные сразу; свежесть тихо приходит следом. gcTime отвечает на другой вопрос: «после размонтирования последнего компонента, использующего запись, сколько держать её в памяти?» Активная запись не собирается никогда; неактивная живёт gcTime (по умолчанию 5 минут), чтобы возврат назад в этом окне был мгновенным кеш-хитом, а не спиннером.
Дефолты сознательно агрессивны: staleTime: 0 — всё устаревает немедленно, поэтому каждый маунт и каждый фокус окна запускают фоновый перезапрос; плюс упавшие запросы ретраятся 3 раза с экспоненциальным бэкоффом. Команды открывают это как «почему наш API долбят?» — и ответ не в глобальном отключении refetchOnWindowFocus (перезапрос по фокусу — то, что делает дашборд честным, когда пользователь вернулся с обеда), а в честном staleTime на ресурс: 30 секунд для списка задач, 10 минут для списка стран, 0 для котировки. Неверная настройка таймеров ломается по-разному: слишком большой staleTime отдаёт неправду; слишком большой gcTime держит мёртвые данные в памяти; а gcTime меньше staleTime тихо обессмысливает свежесть — запись соберут раньше, чем вернувшийся посетитель ею воспользуется.
У запроса staleTime: 60_000 и gcTime: 300_000. Компонент размонтируется через 10 секунд; пользователь возвращается через 40 секунд. Что происходит при ремаунте?
Мутации замыкают цикл — и настоящий счёт библиотеки
Чтения — половина истории. useMutation оборачивает запись, и в onSuccess вы примиряете кеш с новой серверной реальностью — проще всего через invalidateQueries({ queryKey: ['todos'] }): он матчит по префиксу каждый ключ, начинающийся с 'todos', помечает их устаревшими и немедленно перезапрашивает активные (неактивные лишь получают флаг и перезапросятся при следующем использовании). Это то, чего у паттерна с эффектами не было никогда: после POST ваши рукописные useState-кеши в пяти компонентах все молча неверны, и никакая гигиена эффектов не чинит данные, которые больше не отражают сервер.
Подобьём, что библиотека реально экономит против базы из прошлого урока: дедупликация в полёте (шесть подписчиков — один запрос) и общие записи кеша; встроенная защита от гонок — ответы сопоставляются со своими запросами, а queryFn получает AbortSignal, подключённый к отмене запроса; ретраи с экспоненциальным бэкоффом; жизненный цикл — устаревание, перезапрос по фокусу и реконнекту, сборка мусора; и путь записи — инвалидация плюс оптимистичные обновления (следующий урок). Вместе эти пять свойств означают, что каждый компонент, читающий одни данные, остаётся синхронизированным при записях, сбоях сети и возвратах на вкладку без единой строки boilerplate — уберите любое из них, и вы снова пишете его сами. Честные издержки: зависимость в критическом пути (~12 КБ gzip ядро), дисциплина проектирования ключей (ключи — схема вашего кеша; централизуйте их в фабрике, иначе разбросанные ad-hoc-ключи превращают точечную инвалидацию в гадание) и дефолты, которые надо осознанно настраивать. Трейдофф вечный: кеша вы не избегаете — вы выбираете между проверенным и тем, что напишете случайно.
Список задач постраничный: queryFn запрашивает /api/todos?page=N, но queryKey — просто ['todos']. Пользователь листает со страницы 1 на страницу 2. Что делает кеш и что видит пользователь?
- 01Объясните staleTime против gcTime точно: на какой вопрос отвечает каждый таймер, каковы дефолты и каким отдельным отказом оборачивается неверная настройка каждого?
- 02Коллега говорит: «TanStack Query — это useEffect с лишними шагами, я и сам напишу fetch». Перечислите по пунктам, что заменяет библиотека, привязав каждый пункт к отказу, который он предотвращает.
Query-библиотеки начинаются с переосмысления: серверные данные — не состояние компонента, а клиентский кеш состояния, которым владеет сервер, — а у кешей известные проблемы с известными решениями. В TanStack Query queryKey — идентичность кеша: структурно хешируемый массив, куда обязан попасть каждый вход queryFn (недоключованный постраничный запрос позволяет странице 2 перезаписать страницу 1 по одному адресу), и каждый компонент с тем же ключом разделяет одну дедуплицированную запись — шесть читателей ['me'] ради аватара, прав и персонализации схлопываются в один запрос. Каждая запись живёт на двух независимых таймерах: staleTime управляет ревалидацией — свежие записи отдают маунты и фокусы из кеша с нулём запросов, устаревшие отдаются мгновенно, пока следующий триггер запускает фоновый перезапрос (stale-while-revalidate); gcTime управляет памятью — активные записи не собираются, неактивные живут по умолчанию пять минут, делая возврат назад мгновенным. Дефолты агрессивны (staleTime 0, три ретрая с экспоненциальным бэкоффом, перезапрос по фокусу и реконнекту), поэтому штормы перезапросов лечатся честным staleTime на ресурс, а не отключением фокус-рефетча, который держит долгоживущие вкладки честными. Записи замыкают цикл: useMutation плюс invalidateQueries матчит затронутые ключи по префиксу, помечает их устаревшими и перезапрашивает активные — шаг примирения, которого у паттерна с эффектами не было. Счёт: ~12 КБ зависимость, дисциплина ключей (централизуйте фабрики) и осознанно настроенные дефолты — против кеша, который вы иначе напишете случайно. Теперь, когда увидишь API под бомбардировкой или данные, лгущие до F5, первый вопрос звучит так: каков staleTime для этого ресурса и входит ли в queryKey каждый вход, который читает fetcher?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.