В распределённых пользовательских интерфейсах состояние данных перестаёт быть линейным. Один и тот же ресурс может изменяться одновременно из нескольких источников: фоновые refetch-запросы, мутации, синхронизация вкладок, восстановление после офлайн-режима, предзагрузка маршрутов. В TanStack Query это приводит к ситуации, когда несколько версий одного и того же query существуют одновременно и конкурируют за право стать актуальной.
Конфликт данных в контексте TanStack Query — это расхождение между:
Механизм работы библиотеки не предполагает блокировки данных. Вместо этого используется стратегия «последний валидный результат побеждает», но с дополнительными правилами, зависящими от идентичности запроса, времени запроса и жизненного цикла подписчиков.
TanStack Query определяет уникальность данных через
queryKey. Любое изменение структуры ключа создаёт новую
область кэша, но в реальных приложениях часто возникают логические
пересечения.
Пример:
useQuery({
queryKey: ['users', { page: 1 }],
queryFn: fetchUsers
})
useQuery({
queryKey: ['users', { page: 2 }],
queryFn: fetchUsers
})
Формально это разные запросы, но они принадлежат одной доменной сущности — списку пользователей. Это означает, что мутация или инвалидирование может затронуть сразу несколько query.
Конфликты возникают, когда:
Одна из наиболее частых причин конфликтов — параллельное выполнение мутации и фонового refetch.
Сценарий:
useMutation (например, обновление
пользователя)Если не задана стратегия синхронизации, возможна ситуация, когда старый refetch перезапишет актуальное состояние.
TanStack Query не блокирует refetch во время мутации. Оба запроса считаются независимыми, а итоговое состояние зависит от порядка завершения.
Race condition возникает, когда два запроса одного queryKey выполняются асинхронно, и порядок их завершения не совпадает с порядком запуска.
Пример:
queryClient.fetchQuery({
queryKey: ['profile'],
queryFn: () => fetch('/api/profile?v=1')
})
queryClient.fetchQuery({
queryKey: ['profile'],
queryFn: () => fetch('/api/profile?v=2')
})
Если второй запрос завершится быстрее, он установит более свежие данные. Однако если первый завершится позже, он перезапишет кэш устаревшим состоянием.
TanStack Query использует несколько уровней защиты:
Каждый запрос связан с observer-ами. Если компонент уже размонтирован, результат может быть проигнорирован.
Каждый запрос имеет внутренний идентификатор выполнения. При завершении запроса библиотека проверяет, актуален ли он относительно последнего запущенного запроса.
При поддержке signal запрос может быть отменён:
queryFn: async ({ signal }) => {
const res = await fetch('/api/data', { signal })
return res.json()
}
Это снижает вероятность конфликтов, но не устраняет их полностью, особенно если API не поддерживает отмену.
Optimistic updates создают временное состояние, которое позже может быть либо подтверждено, либо перезаписано сервером.
Сценарий:
setQueryDataЕсли серверный ответ не синхронизирован с optimistic state, происходит перезапись.
Наиболее частая ошибка — отсутствие rollback логики:
onMutate: async (newData) => {
await queryClient.cancelQueries(['item', id])
const previous = queryClient.getQueryData(['item', id])
queryClient.setQueryData(['item', id], newData)
return { previous }
}
onError: (err, newData, context) => {
queryClient.setQueryData(['item', id], context.previous)
}
Если rollback не реализован, optimistic state становится источником неконсистентности.
invalidateQueries является мощным инструментом, но часто
становится источником неожиданных обновлений.
При вызове:
queryClient.invalidateQueries(['posts'])
все query, начинающиеся с ['posts'], помечаются как
stale.
Конфликты возникают в случаях:
Модель stale-while-revalidate предполагает, что UI может отображать устаревшие данные, пока идет фоновое обновление.
Конфликт проявляется как:
Особенно заметно при:
Несколько компонентов могут подписываться на один и тот же queryKey:
useQuery({ queryKey: ['cart'], queryFn })
useQuery({ queryKey: ['cart'], queryFn })
Каждый observer получает обновления независимо, но конфликт возникает на уровне UI, когда:
Когда несколько мутаций изменяют один и тот же queryKey, порядок их завершения становится критическим.
Пример:
Если оба запроса выполняются параллельно, последний завершившийся перезапишет часть данных предыдущего, если сервер возвращает не полный объект.
Наиболее точный способ управления состоянием:
queryClient.setQueryData(['user', id], old => ({
...old,
...patch
}))
Это предотвращает потерю полей при частичных обновлениях.
queryClient.cancelQueries(['user', id])
Позволяет избежать перезаписи optimistic state.
Увеличение staleTime снижает частоту refetch и уменьшает
вероятность гонок.
staleTime: 1000 * 60
Структурирование ключей снижает перекрытия:
['user', id]['user', id, 'profile']['user', id, 'settings']Hydration из SSR-данных может конфликтовать с уже загруженными клиентскими данными.
Сценарий:
Для предотвращения используется:
dehydrate/hydrate стратегически на уровне
приложенияКаждый query хранит timestamp последнего успешного обновления. Этот параметр используется для определения «старшинства» данных.
При конкуренции двух результатов:
dataUpdatedAt побеждаетМеханика разрешения конфликтов основана не на блокировках, а на комбинации:
TanStack Query не устраняет конфликты, а делает их управляемыми и предсказуемыми через слой кэш-абстракции, где каждое обновление рассматривается как потенциально конкурентное событие, а итоговое состояние формируется из набора детерминированных правил приоритета.