Оптимистический UI строится на предположении, что операция на сервере
завершится успешно, и интерфейс обновляется заранее, до фактического
ответа. TanStack Query предоставляет для этого набор низкоуровневых
механизмов вокруг useMutation и QueryClient,
позволяющих контролировать кэш, выполнять откаты и синхронизировать
состояние после завершения запроса.
Основная идея заключается в том, чтобы изменить локальный кэш так, будто сервер уже подтвердил операцию, а затем при необходимости восстановить предыдущее состояние.
Ключевые точки управления находятся в useMutation:
onMutate — момент перед отправкой запросаonError — обработка ошибки и откат состоянияonSuccess — финальное подтверждениеonSettled — синхронизация с серверомПростейший сценарий обновления списка:
import { useMutation, useQueryClient } from '@tanstack/react-query'
function useToggleTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, completed }) => {
const res = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify({ completed })
})
return res.json()
},
onMutate: async ({ id, completed }) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previousTodos = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) => {
return old.map(todo =>
todo.id === id ? { ...todo, completed } : todo
)
})
return { previousTodos }
},
onError: (_err, _vars, context) => {
queryClient.setQueryData(['todos'], context.previousTodos)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
})
}
Ключевая концепция optimistic UI — сохранение snapshot до изменения кэша.
const previousState = queryClient.getQueryData(['todos'])
Этот снимок используется как источник восстановления при ошибке. TanStack Query не хранит его автоматически — это ответственность прикладного кода.
Типичные данные для восстановления:
Существует два подхода после успешной или неуспешной мутации:
Используется в критичных UI-сценариях, где важна мгновенная консистентность.
queryClient.setQueryData(['todos'], updater)
Преимущество — отсутствие лишнего запроса.
queryClient.invalidateQueries({ queryKey: ['todos'] })
Преимущество — гарантированная синхронизация с сервером.
Недостаток — дополнительный сетевой запрос.
Создание объекта требует временного идентификатора.
function useCreateTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (newTodo) => {
const res = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo)
})
return res.json()
},
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previous = queryClient.getQueryData(['todos'])
const optimisticTodo = {
id: `temp-${Date.now()}`,
...newTodo
}
queryClient.setQueryData(['todos'], (old = []) => [
optimisticTodo,
...old
])
return { previous }
},
onError: (_err, _vars, ctx) => {
queryClient.setQueryData(['todos'], ctx.previous)
},
onSuccess: (serverTodo) => {
queryClient.setQueryData(['todos'], (old = []) =>
old.map(todo =>
todo.id.startsWith('temp-') ? serverTodo : todo
)
)
}
})
}
Удаление — наиболее простой случай, но требует аккуратного rollback.
onMutate: async ({ id }) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previous = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) =>
old.filter(todo => todo.id !== id)
)
return { previous }
}
Infinite queries требуют обновления вложенной структуры:
queryClient.setQueryData(['todos', 'infinite'], (old) => {
return {
...old,
pages: old.pages.map(page => ({
...page,
data: page.data.filter(todo => todo.id !== id)
}))
}
})
Основная сложность — сохранение структуры pages и
pageParams.
При параллельных мутациях возможны гонки состояний. Для их контроля используется:
cancelQueriesmutationIdПример изоляции контекста:
onMutate: async (vars) => {
const previous = queryClient.getQueryData(['todos'])
return {
previous,
mutationId: crypto.randomUUID()
}
}
Rollback происходит только при ошибке мутации:
onError: (_err, _vars, context) => {
queryClient.setQueryData(['todos'], context.previous)
}
Важно учитывать, что rollback может конфликтовать с новыми данными, пришедшими с сервера. В таких случаях применяется:
Одна мутация может затрагивать несколько кэшей одновременно:
onMutate: async ({ todo }) => {
const prevTodos = queryClient.getQueryData(['todos'])
const prevStats = queryClient.getQueryData(['stats'])
queryClient.setQueryData(['todos'], updateTodos(todo))
queryClient.setQueryData(['stats'], updateStats(todo))
return { prevTodos, prevStats }
}
Сервер всегда остаётся источником истины. Оптимистические изменения:
invalidateQueries или
merge-обновлениеТипичный гибридный подход:
setQueryDataonSuccessinvalidateQueriesЕсли не сохранить предыдущие данные, rollback невозможен.
При infinite queries или вложенных объектах это приводит к разрушению кэша.
Без cancelQueries возможны race conditions между
optimistic и серверными данными.
Один и тот же объект должен обновляться во всех связанных кэшах.
В сложных интерфейсах применяется модель:
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
queryClient.invalidateQueries({ queryKey: ['stats'] })
}
Чем выше latency, тем сильнее эффект оптимистического UI. При низкой задержке серверные данные могут приходить быстрее, чем rollback или sync, что создаёт визуальные скачки. Для сглаживания применяется:
Иногда сервер возвращает не полную сущность, а дельту:
onSuccess: (patch) => {
queryClient.setQueryData(['todos'], (old) =>
old.map(todo =>
todo.id === patch.id ? { ...todo, ...patch } : todo
)
)
}
Такой подход снижает нагрузку и уменьшает перерисовки.
Оптимистические обновления тесно связаны с политикой кэша:
staleTime влияет на частоту рефетчаcacheTime определяет срок жизни optimistic данныхПри агрессивных optimistic updates слишком маленький
cacheTime приводит к частому сбросу состояния, нивелируя
эффект мгновенного UI.