Конфликты данных

В распределённых пользовательских интерфейсах состояние данных перестаёт быть линейным. Один и тот же ресурс может изменяться одновременно из нескольких источников: фоновые refetch-запросы, мутации, синхронизация вкладок, восстановление после офлайн-режима, предзагрузка маршрутов. В TanStack Query это приводит к ситуации, когда несколько версий одного и того же query существуют одновременно и конкурируют за право стать актуальной.

Конфликт данных в контексте TanStack Query — это расхождение между:

  • локально закэшированным состоянием query
  • результатом активного сетевого запроса
  • результатом мутации
  • внешним обновлением (refetch, invalidate, sync)

Механизм работы библиотеки не предполагает блокировки данных. Вместо этого используется стратегия «последний валидный результат побеждает», но с дополнительными правилами, зависящими от идентичности запроса, времени запроса и жизненного цикла подписчиков.


Идентичность query как источник потенциальных конфликтов

TanStack Query определяет уникальность данных через queryKey. Любое изменение структуры ключа создаёт новую область кэша, но в реальных приложениях часто возникают логические пересечения.

Пример:

useQuery({
  queryKey: ['users', { page: 1 }],
  queryFn: fetchUsers
})

useQuery({
  queryKey: ['users', { page: 2 }],
  queryFn: fetchUsers
})

Формально это разные запросы, но они принадлежат одной доменной сущности — списку пользователей. Это означает, что мутация или инвалидирование может затронуть сразу несколько query.

Конфликты возникают, когда:

  • общий ресурс обновляется через invalidateQueries
  • разные страницы данных перекрываются по смыслу
  • параметры запроса нормализованы неодинаково

Конкуренция между refetch и мутациями

Одна из наиболее частых причин конфликтов — параллельное выполнение мутации и фонового refetch.

Сценарий:

  1. UI инициирует useMutation (например, обновление пользователя)
  2. В этот момент происходит автоматический refetch активного query
  3. Refetch возвращает старые данные
  4. Мутация возвращает новые данные

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

Поведение по умолчанию

TanStack Query не блокирует refetch во время мутации. Оба запроса считаются независимыми, а итоговое состояние зависит от порядка завершения.


Race condition и временные гонки

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 использует несколько уровней защиты:

1. Query Observer Tracking

Каждый запрос связан с observer-ами. Если компонент уже размонтирован, результат может быть проигнорирован.

2. Query instance signature

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

3. Cancellation via AbortController

При поддержке signal запрос может быть отменён:

queryFn: async ({ signal }) => {
  const res = await fetch('/api/data', { signal })
  return res.json()
}

Это снижает вероятность конфликтов, но не устраняет их полностью, особенно если API не поддерживает отмену.


Конфликты между optimistic updates и серверным ответом

Optimistic updates создают временное состояние, которое позже может быть либо подтверждено, либо перезаписано сервером.

Сценарий:

  1. Пользователь изменяет данные
  2. UI мгновенно обновляет cache через setQueryData
  3. Запускается mutation
  4. Сервер возвращает результат, отличающийся от оптимистичного состояния

Если серверный ответ не синхронизирован с 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.

Конфликты возникают в случаях:

  • частичной инвалидизации данных с разной степенью детализации
  • одновременного вызова invalidate из нескольких мутаций
  • параллельного refetch нескольких зависимых query

Stale-while-revalidate и визуальные расхождения

Модель stale-while-revalidate предполагает, что UI может отображать устаревшие данные, пока идет фоновое обновление.

Конфликт проявляется как:

  • кратковременное «прыгание» данных
  • возврат старого значения после обновления
  • несогласованность между списком и деталями объекта

Особенно заметно при:

  • пагинации
  • бесконечной прокрутке
  • связанных сущностях (например, post → comments)

Конфликты между несколькими компонентами одного query

Несколько компонентов могут подписываться на один и тот же queryKey:

useQuery({ queryKey: ['cart'], queryFn })

useQuery({ queryKey: ['cart'], queryFn })

Каждый observer получает обновления независимо, но конфликт возникает на уровне UI, когда:

  • один компонент применяет локальную трансформацию данных
  • другой получает «чистый» кэш
  • происходит race между setQueryData и refetch

Параллельные мутации одного ресурса

Когда несколько мутаций изменяют один и тот же queryKey, порядок их завершения становится критическим.

Пример:

  • обновление имени пользователя
  • обновление email пользователя

Если оба запроса выполняются параллельно, последний завершившийся перезапишет часть данных предыдущего, если сервер возвращает не полный объект.


Стратегии управления конфликтами через queryClient

1. Явная синхронизация через setQueryData

Наиболее точный способ управления состоянием:

queryClient.setQueryData(['user', id], old => ({
  ...old,
  ...patch
}))

Это предотвращает потерю полей при частичных обновлениях.


2. Блокировка refetch во время мутации

queryClient.cancelQueries(['user', id])

Позволяет избежать перезаписи optimistic state.


3. Контроль staleTime

Увеличение staleTime снижает частоту refetch и уменьшает вероятность гонок.

staleTime: 1000 * 60

4. Разделение queryKey по уровням консистентности

Структурирование ключей снижает перекрытия:

  • ['user', id]
  • ['user', id, 'profile']
  • ['user', id, 'settings']

Конфликты при гидратации состояния

Hydration из SSR-данных может конфликтовать с уже загруженными клиентскими данными.

Сценарий:

  • сервер передаёт начальный cache
  • клиент уже успел выполнить fetch
  • hydration перезаписывает более свежие данные

Для предотвращения используется:

  • сравнение timestamp
  • dehydrate/hydrate стратегически на уровне приложения
  • отказ от перезаписи при наличии более свежего dataUpdatedAt

dataUpdatedAt как механизм разрешения конфликтов

Каждый query хранит timestamp последнего успешного обновления. Этот параметр используется для определения «старшинства» данных.

При конкуренции двух результатов:

  • более свежий dataUpdatedAt побеждает
  • при равенстве учитывается порядок завершения запроса

Итоговая модель разрешения конфликтов в TanStack Query

Механика разрешения конфликтов основана не на блокировках, а на комбинации:

  • идентичности queryKey
  • временных меток обновления
  • отмены запросов через AbortController
  • стратегии optimistic updates
  • инвалидизации и refetch политики
  • локального управления cache через queryClient

TanStack Query не устраняет конфликты, а делает их управляемыми и предсказуемыми через слой кэш-абстракции, где каждое обновление рассматривается как потенциально конкурентное событие, а итоговое состояние формируется из набора детерминированных правил приоритета.