Request deduplication

В TanStack Query дедупликация запросов представляет собой механизм предотвращения повторного выполнения одинаковых запросов к серверу при наличии нескольких подписчиков или параллельных вызовов одного и того же queryKey. Базовая идея заключается в том, что один сетевой запрос может обслуживать несколько потребителей данных, если их контекст совпадает.

Ключевая единица дедупликации — это queryHash, который вычисляется на основе queryKey и нормализованной структуры запроса. Если два запроса имеют одинаковый queryHash, библиотека считает их идентичными.


Область действия дедупликации

Дедупликация в TanStack Query работает в рамках одного QueryClient и распространяется на:

  • одновременные вызовы useQuery с одинаковым queryKey
  • параллельные вызовы queryClient.fetchQuery
  • повторные подписки на уже выполняющийся запрос
  • автоматические фоновые refetch-операции, если запрос уже активен

Важно, что дедупликация не зависит от компонента или места вызова. Она работает на уровне кэша и менеджера запросов.


Поведение при одинаковых queryKey

При нескольких одновременных запросах с одинаковым ключом библиотека не инициирует отдельные HTTP-вызовы. Вместо этого:

  1. Первый запрос запускает queryFn
  2. Последующие запросы подписываются на уже выполняющийся промис
  3. Все подписчики получают единый результат

Пример:

import { useQuery } fr om '@tanstack/react-query'

function A() {
  const query = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  })

  return null
}

function B() {
  const query = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  })

  return null
}

Если компоненты A и B смонтированы одновременно, fetchUsers выполнится только один раз.


Внутренний механизм объединения запросов

Когда инициируется запрос, TanStack Query создаёт или переиспользует объект Query, связанный с конкретным queryHash. Внутри него хранится состояние:

  • promise — текущий выполняющийся запрос
  • observers — список подписчиков
  • state — данные, ошибки, статус загрузки

Если запрос уже находится в состоянии fetching, новые подписчики не создают новый queryFn вызов, а присоединяются к существующему promise.

Упрощённая модель:

if (query.isFetching) {
  return query.promise
} else {
  query.promise = queryFn()
  return query.promise
}

Дедупликация через fetchQuery и prefetchQuery

Дедупликация работает не только в React-хуках, но и на уровне клиента:

await queryClient.fetchQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
})

Если в момент вызова уже выполняется запрос с таким же ключом, второй вызов не создаёт новый HTTP-запрос, а ожидает результат первого.

То же поведение применимо к:

  • prefetchQuery
  • ensureQueryData

Влияние подписчиков на жизненный цикл запроса

Запрос считается активным, пока у него есть подписчики. Подписчики появляются при:

  • использовании useQuery
  • ручной подписке через queryClient.getQueryCache().subscribe

Когда последний подписчик отписывается, запрос переходит в состояние inactive, но результат остаётся в кэше до истечения gcTime.

Дедупликация тесно связана с этим механизмом: пока запрос активен, повторный запуск не создаётся.


Отличие дедупликации от кэширования

Дедупликация и кэширование — разные уровни оптимизации.

Дедупликация:

  • работает во время выполнения запроса
  • предотвращает параллельные одинаковые запросы
  • действует только пока запрос “в полёте”

Кэширование:

  • работает после завершения запроса
  • хранит результат в памяти
  • управляется staleTime и gcTime

Пример:

useQuery({
  queryKey: ['user', 1],
  queryFn: fetchUser,
  staleTime: 5000,
})

Если два компонента одновременно монтируются, сработает дедупликация. Если второй компонент смонтируется через минуту — запрос может не выполняться вовсе из-за кэша.


Роль staleTime в дедупликации

staleTime не влияет напрямую на дедупликацию, но влияет на необходимость запроса.

  • при staleTime: Infinity запрос не считается устаревшим
  • повторные монтирования используют кэш без сети
  • дедупликация становится менее заметной, так как сеть не используется

Однако при одновременном запуске нескольких запросов даже с staleTime: Infinity, если данных нет в кэше, дедупликация всё равно активируется.


Поведение при refetch

При refetch дедупликация работает только если запрос уже выполняется.

queryClient.invalidateQueries({ queryKey: ['todos'] })

Если несколько компонентов одновременно вызывают invalidation, TanStack Query объединяет рефетчи:

  • один реальный сетевой запрос
  • остальные подписываются на результат

Но если предыдущий запрос завершён, новый refetch создаёт отдельный вызов.


QueryKey как основа идентификации

Любая дедупликация строится на нормализованном queryKey. Примеры:

['users']
['users', { page: 1 }]
['users', { page: 2 }]

Эти ключи считаются разными запросами.

Однако важно учитывать, что порядок и структура объекта влияет на hash:

['users', { page: 1, lim it: 10 }]
['users', { limit: 10, page: 1 }]

Внутри TanStack Query происходит нормализация, поэтому такие ключи считаются одинаковыми.


Параллельные запросы с одинаковым fetchFn

Дедупликация не зависит от queryFn. Даже если функции разные по ссылке, но queryKey одинаковый, результат будет один.

useQuery({
  queryKey: ['data'],
  queryFn: () => fetch('/api/a'),
})

useQuery({
  queryKey: ['data'],
  queryFn: () => fetch('/api/b'),
})

Фактически будет использоваться один из queryFn (тот, который был зарегистрирован первым для данного key), поэтому такая конфигурация считается ошибочной и может приводить к неопределённому поведению.


Поведение при ошибках

Если дедуплицированный запрос завершился ошибкой:

  • ошибка кэшируется
  • все подписчики получают одно и то же состояние error
  • повторный запуск возможен по правилам retry

Важно, что повторный retry также дедуплицируется, если он инициирован одновременно несколькими подписчиками.


retry и дедупликация

При включённом retry:

useQuery({
  queryKey: ['profile'],
  queryFn: fetchProfile,
  retry: 3,
})

Если несколько компонентов инициируют запрос одновременно, все retry-итерации объединяются. Это предотвращает ситуацию, когда каждый подписчик отдельно повторяет запросы.


Влияние networkMode

Параметр networkMode может изменять поведение дедупликации:

  • online — стандартное поведение
  • always — запросы выполняются даже при оффлайне
  • offlineFirst — может менять стратегию получения данных

Однако сама дедупликация по queryKey сохраняется во всех режимах.


Граничные случаи дедупликации

Повторный mount компонента

Если компонент размонтируется и монтируется снова, но запрос ещё активен:

  • новый useQuery присоединяется к текущему запросу
  • новый сетевой вызов не создаётся

Быстрое переключение маршрутов

При SPA-навигации несколько страниц могут запрашивать одинаковые данные:

  • TanStack Query объединяет запросы
  • предотвращается “waterfall” запросов

Server-side hydration

При гидратации состояния дедупликация может происходить уже после восстановления кэша, предотвращая повторный запрос на клиенте.


Практическое значение дедупликации

Дедупликация снижает:

  • количество сетевых запросов
  • нагрузку на backend
  • вероятность race conditions
  • дублирование данных в UI

Особенно заметно в приложениях с:

  • глобальными данными пользователя
  • списками сущностей (users, posts, comments)
  • shared layout-компонентами

Ограничения механизма

Несмотря на эффективность, дедупликация имеет ограничения:

  • работает только в рамках одного QueryClient
  • не синхронизируется между вкладками браузера
  • зависит от корректности queryKey
  • не предотвращает разные запросы с разными ключами даже при одинаковом queryFn