Конфликт в TanStack Query возникает в момент, когда несколько операций одновременно изменяют или используют одни и те же данные. На практике это проявляется в нескольких сценариях:
Поскольку TanStack Query работает с асинхронным серверным состоянием, проблема конфликтов является частью архитектуры приложения, а не редким исключением.
Наиболее распространённый случай:
Пример:
queryClient.setQueryData(['todo', id], old => ({
...old,
completed: true
}))
Если сервер отклонит изменение или изменит дополнительные поля, локальный кэш станет неконсистентным.
Несколько мутаций могут выполняться одновременно:
mutationA.mutate(dataA)
mutationB.mutate(dataB)
Если обе мутации обновляют один query cache, результат зависит от порядка завершения запросов, а не от порядка вызова.
Это создаёт condition race.
Сценарий:
Такой конфликт особенно заметен при медленном API.
После invalidateQueries несколько страниц могут обновляться независимо:
queryClient.invalidateQueries({
queryKey: ['posts']
})
Если страницы обновляются в разное время, часть интерфейса может содержать новые данные, а часть — старые.
HTTP-запросы не гарантируют порядок завершения.
Более поздний запрос может завершиться раньше предыдущего:
Request A ---> 900ms
Request B ---> 200ms
Результат:
B completed first
A overwrote cache later
TanStack Query не является transactional state manager.
Он не синхронизирует изменения автоматически между:
Чрезмерное использование invalidation приводит к хаотическим refetch:
onSuccess: () => {
queryClient.invalidateQueries()
}
Подобный код способен вызвать лавину обновлений.
Перед оптимистичным обновлением рекомендуется отменять активные запросы:
await queryClient.cancelQueries({
queryKey: ['todos']
})
Это предотвращает перезапись локальных изменений старыми ответами.
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async updatedTodo => {
await queryClient.cancelQueries({
queryKey: ['todos']
})
const previousTodos =
queryClient.getQueryData(['todos'])
queryClient.setQueryData(
['todos'],
old => old.map(todo =>
todo.id === updatedTodo.id
? { ...todo, ...updatedTodo }
: todo
)
)
return { previousTodos }
},
onError: (err, variables, context) => {
queryClient.setQueryData(
['todos'],
context.previousTodos
)
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ['todos']
})
}
})
Перед изменением кэша создаётся snapshot предыдущего состояния:
const previousData =
queryClient.getQueryData(['todos'])
При ошибке выполняется откат:
queryClient.setQueryData(
['todos'],
previousData
)
Rollback становится сложным, если:
Проблемный сценарий:
Mutation A optimistic update
Mutation B optimistic update
Mutation B success
Mutation A rollback
Rollback первой мутации уничтожит результат второй.
Для каждой мутации необходимо хранить собственный snapshot:
onMutate: async newTodo => {
const previous =
queryClient.getQueryData(['todos'])
return { previous }
}
Контекст мутации должен быть независимым.
Самая простая стратегия.
Последнее изменение перезаписывает предыдущие:
queryClient.setQueryData(key, newData)
Преимущества:
Недостатки:
Сервер считается единственным источником истины.
После каждой мутации выполняется refetch:
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['todos']
})
}
Преимущества:
Недостатки:
Локальные и серверные изменения объединяются:
queryClient.setQueryData(
['profile'],
old => ({
...old,
...serverData
})
)
Подход полезен для:
Каждая сущность содержит версию:
{
id: 1,
title: 'Post',
version: 5
}
Перед обновлением сервер проверяет version.
Если версия изменилась:
409 Conflict
Клиент может:
const mutation = useMutation({
mutationFn: updatePost,
onError: error => {
if (error.response?.status === 409) {
console.log('Conflict detected')
}
}
})
После 409 обычно выполняется refetch:
await queryClient.invalidateQueries({
queryKey: ['post', id]
})
Некоторые API возвращают актуальные данные прямо в 409 response:
{
"message": "Conflict",
"currentData": {
"title": "Updated"
}
}
Тогда можно обновить кэш напрямую:
queryClient.setQueryData(
['post', id],
error.response.data.currentData
)
Иногда мутации необходимо выполнять строго последовательно.
Пример:
await mutation.mutateAsync(step1)
await mutation.mutateAsync(step2)
await mutation.mutateAsync(step3)
Это предотвращает гонки состояния.
Можно блокировать повторную мутацию:
if (mutation.isPending) {
return
}
<button disabled={mutation.isPending}>
Save
</button>
Это уменьшает вероятность конфликтов пользовательского ввода.
TanStack Query автоматически объединяет одинаковые запросы.
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos
})
Если несколько компонентов одновременно используют одинаковый queryKey, будет выполнен один HTTP-запрос.
Это снижает вероятность конфликтов refetch.
Частые refetch увеличивают вероятность race conditions.
Настройка staleTime уменьшает количество фоновых обновлений:
useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 60000
})
Автоматический refetch при фокусе окна может неожиданно перезаписать optimistic state.
useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
refetchOnWindowFocus: false
})
Infinite query хранит массив страниц:
data.pages
Если обновить только одну страницу:
queryClient.setQueryData(
['feed'],
old => ({
...old,
pages: updatedPages
})
)
остальные страницы могут содержать старые сущности.
При cursor pagination после refetch могут появляться дубликаты:
Page 1 -> item 10
Page 2 -> item 10
Необходима нормализация данных:
const unique = Array.from(
new Map(items.map(i => [i.id, i])).values()
)
Сервер может прислать websocket update во время optimistic mutation.
Проблема:
optimistic update
websocket update
mutation response
Каждый источник пытается обновить cache.
Для разрешения конфликта используют timestamps:
if (incoming.updatedAt > current.updatedAt) {
return incoming
}
Несколько вкладок браузера имеют независимый query cache.
Проблемы:
Плагин синхронизации вкладок:
broadcastQueryClient({
queryClient,
broadcastChannel: 'app-cache'
})
Изменения query cache будут распространяться между вкладками.
queryClient.invalidateQueries()
Минусы:
queryClient.invalidateQueries({
queryKey: ['todos', id]
})
Предпочтительнее инвалидировать только затронутые сущности.
Вместо полного refetch можно обновлять кэш вручную:
queryClient.setQueryData(
['todo', id],
updatedTodo
)
Преимущества:
Недостаток — усложнение логики синхронизации.
Опасный код:
old.title = 'New'
return old
Безопасный вариант:
return {
...old,
title: 'New'
}
Mutation-in-place может привести к скрытым конфликтам и некорректным рендерам.
Одна сущность часто хранится в нескольких query:
['todos']
['todo', id]
['dashboard']
При обновлении только одного query возникает рассинхрон.
queryClient.setQueryData(
['todo', id],
updatedTodo
)
queryClient.setQueryData(
['todos'],
old => old.map(todo =>
todo.id === id
? updatedTodo
: todo
)
)
Автоматический retry способен усугубить проблему.
useMutation({
mutationFn: saveData,
retry: 3
})
Если сервер уже частично обработал запрос, повторная отправка может создать повторное изменение.
Безопаснее использовать idempotent API:
PUT /resource/1
вместо:
POST /resource
Для сложных систем полезно логировать:
Пример:
onError: (error, vars, ctx) => {
console.log({
error,
vars,
previous: ctx.previous
})
}
Большие приложения часто переходят к entity normalization:
{
entities: {
todos: {
1: {...},
2: {...}
}
}
}
Это уменьшает вероятность конфликтов между query.
Вместо немедленного обновления состояния фиксируются события:
TODO_UPDATED
TODO_COMPLETED
TODO_REMOVED
После этого вычисляется итоговое состояние.
Подход сложнее, но лучше масштабируется в real-time системах.
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async variables => {
await queryClient.cancelQueries({
queryKey: ['todos']
})
const previous =
queryClient.getQueryData(['todos'])
queryClient.setQueryData(
['todos'],
old => old.map(todo =>
todo.id === variables.id
? { ...todo, ...variables }
: todo
)
)
return { previous }
},
onError: (error, variables, context) => {
queryClient.setQueryData(
['todos'],
context.previous
)
},
onSuccess: serverTodo => {
queryClient.setQueryData(
['todo', serverTodo.id],
serverTodo
)
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ['todos']
})
}
})
В этом шаблоне объединены: