open atlas
↑ К треку
React с нуля до senior RCT · 03 · 03

Query-библиотеки: идентичность queryKey, два таймера и что TanStack Query реально экономит

TanStack Query относится к серверным данным как к кешу, а не состоянию: queryKey — идентичность кеша (дедупликация), staleTime и gcTime — два независимых таймера (перезапрос и память), refetch по фокусу держит данные живыми, а мутации с invalidateQueries замыкают цикл записи.

RCT Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Первой эскалировала команда бэкенда: «/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. Что делает кеш и что видит пользователь?

Вспомните перед уходом
  1. 01
    Объясните staleTime против gcTime точно: на какой вопрос отвечает каждый таймер, каковы дефолты и каким отдельным отказом оборачивается неверная настройка каждого?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.