Optimistic update — подход, при котором интерфейс изменяется ещё до завершения HTTP-запроса. Пользователь нажимает кнопку, а данные мгновенно обновляются локально, будто сервер уже подтвердил операцию.
Классический сценарий:
Без optimistic updates:
С optimistic updates:
Такой подход делает интерфейс визуально быстрым даже при медленном интернете.
Optimistic updates обычно строятся через:
useMutationonMutateonErroronSettledqueryClient.setQueryDataКлючевая идея:
[
{ id: 1, title: 'Learn JS', completed: false },
{ id: 2, title: 'Learn React', completed: false }
]
import { useQuery } from '@tanstack/react-query'
function Todos() {
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos
})
return (
<div>
{data?.map(todo => (
<div key={todo.id}>
{todo.title}
</div>
))}
</div>
)
}
import { useMutation, useQueryClient } from '@tanstack/react-query'
function TodoItem({ todo }) {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: updateTodo,
async onMutate(updatedTodo) {
await queryClient.cancelQueries({
queryKey: ['todos']
})
const previousTodos =
queryClient.getQueryData(['todos'])
queryClient.setQueryData(
['todos'],
old => {
return old.map(todo =>
todo.id === updatedTodo.id
? { ...todo, ...updatedTodo }
: todo
)
}
)
return { previousTodos }
},
onError(error, variables, context) {
queryClient.setQueryData(
['todos'],
context.previousTodos
)
},
onSettled() {
queryClient.invalidateQueries({
queryKey: ['todos']
})
}
})
return (
<button
onCl ick={() => {
mutation.mutate({
id: todo.id,
completed: !todo.completed
})
}}
>
Toggle
</button>
)
}
onMutateonMutate запускается до выполнения
mutationFn.
Именно здесь выполняется optimistic update.
async onMutate(updatedTodo) {
}
Аргумент:
updatedTodo
— данные, переданные в mutate.
cancelQueriesawait queryClient.cancelQueries({
queryKey: ['todos']
})
Во время optimistic update может выполняться refetch.
Проблемный сценарий:
cancelQueries предотвращает такую гонку.
const previousTodos =
queryClient.getQueryData(['todos'])
Rollback невозможен без snapshot старых данных.
Чаще всего сохраняют:
setQueryDataqueryClient.setQueryData(
['todos'],
old => {
return old.map(todo =>
todo.id === updatedTodo.id
? { ...todo, ...updatedTodo }
: todo
)
}
)
setQueryData обновляет cache синхронно.
UI перерисовывается мгновенно.
Запрос к серверу ещё даже может не начаться.
onMutatereturn { previousTodos }
Возвращаемое значение попадает в context.
Позже оно доступно:
onError(error, variables, context)
Это основной механизм rollback.
onErroronError(error, variables, context) {
queryClient.setQueryData(
['todos'],
context.previousTodos
)
}
Если запрос завершился ошибкой:
onSettledonSettled() {
queryClient.invalidateQueries({
queryKey: ['todos']
})
}
Даже после успешного optimistic update cache может отличаться от сервера.
Например:
invalidateQueries выполняет финальную синхронизацию.
const mutation = useMutation({
mutationFn: createTodo,
async onMutate(newTodo) {
await queryClient.cancelQueries({
queryKey: ['todos']
})
const previousTodos =
queryClient.getQueryData(['todos'])
queryClient.setQueryData(
['todos'],
old => [
...old,
{
id: Date.now(),
...newTodo,
optimistic: true
}
]
)
return { previousTodos }
},
onError(error, variables, context) {
queryClient.setQueryData(
['todos'],
context.previousTodos
)
},
onSettled() {
queryClient.invalidateQueries({
queryKey: ['todos']
})
}
})
При optimistic create серверного ID ещё нет.
Поэтому используют временные значения:
id: Date.now()
или:
id: crypto.randomUUID()
optimisticПолезно помечать временные записи:
optimistic: true
Это позволяет:
{
data?.map(todo => (
<div
key={todo.id}
style={{
opacity: todo.optimistic ? 0.5 : 1
}}
>
{todo.title}
</div>
))
}
const mutation = useMutation({
mutationFn: deleteTodo,
async onMutate(todoId) {
await queryClient.cancelQueries({
queryKey: ['todos']
})
const previousTodos =
queryClient.getQueryData(['todos'])
queryClient.setQueryData(
['todos'],
old => old.filter(
todo => todo.id !== todoId
)
)
return { previousTodos }
},
onError(error, variables, context) {
queryClient.setQueryData(
['todos'],
context.previousTodos
)
},
onSettled() {
queryClient.invalidateQueries({
queryKey: ['todos']
})
}
})
Без optimistic delete интерфейс выглядит медленным:
Optimistic delete убирает задержку полностью.
Очень частый кейс:
liked: true
completed: false
enabled: true
archived: false
queryClient.setQueryData(
['posts'],
old => {
return old.map(post => {
if (post.id !== postId) {
return post
}
return {
...post,
liked: !post.liked
}
})
}
)
Такие обновления особенно хорошо подходят для optimistic UI, потому что:
Не всегда обновляется список.
Иногда обновляется отдельный объект:
['todo', id]
queryClient.setQueryData(
['todo', todo.id],
old => ({
...old,
title: updatedTitle
})
)
Частая проблема:
['todos']
['todo', 5]
Обе записи cache содержат один объект.
При optimistic update необходимо синхронизировать оба cache entry.
queryClient.setQueryData(
['todos'],
old => {
return old.map(todo =>
todo.id === updated.id
? updated
: todo
)
}
)
queryClient.setQueryData(
['todo', updated.id],
updated
)
Сценарий:
Более старый ответ перезаписал новый state.
Одна мутация откатила изменения другой.
Порядок ответов отличается от порядка действий пользователя.
Самый простой вариант:
disabled={mutation.isPending}
Недостаток:
useMutation({
mutationKey: ['toggleTodo']
})
Позволяет отслеживать конкретные mutation.
useMutationStateconst pendingMutations =
useMutationState({
filters: {
mutationKey: ['toggleTodo'],
status: 'pending'
}
})
Можно анализировать активные optimistic updates.
Infinite query хранит страницы:
{
pages: [],
pageParams: []
}
Обновление становится сложнее.
queryClient.setQueryData(
['feed'],
old => {
return {
...old,
pages: old.pages.map(page => {
return page.map(post => {
if (post.id === updated.id) {
return updated
}
return post
})
})
}
}
)
Один из самых сложных optimistic scenarios.
queryClient.setQueryData(
['tasks'],
old => {
const copy = [...old]
const [removed] = copy.splice(
sourceIndex,
1
)
copy.splice(
destinationIndex,
0,
removed
)
return copy
}
)
Нельзя optimistic обновлять:
Опасные сценарии:
Если backend часто отвечает ошибками:
onSuccess() {
queryClient.invalidateQueries({
queryKey: ['todos']
})
}
Подход:
onMutate() {
queryClient.setQueryData(...)
}
Подход:
На практике чаще используют оба подхода одновременно:
onMutate()
onError()
onSettled()
Где:
onMutate → optimistic state;onError → rollback;onSettled → refetch.Иногда invalidateQueries не нужен.
Сервер возвращает актуальный объект:
mutationFn: updateTodo
Ответ:
{
id: 5,
title: 'Updated',
completed: true
}
Тогда можно обновить cache напрямую:
onSuccess(updatedTodo) {
queryClient.setQueryData(
['todo', updatedTodo.id],
updatedTodo
)
}
Ошибка:
onMutate() {
queryClient.setQueryData(...)
}
Без onError UI может навсегда остаться в неверном
состоянии.
Старый refetch способен затереть optimistic cache.
Ошибка:
old.push(newTodo)
return old
Нельзя мутировать cache напрямую.
Правильно:
return [...old, newTodo]
Обновлён:
['todos']
Но не обновлён:
['todo', id]
В результате разные части UI показывают разные данные.
const mutation = useMutation({
mutationFn: apiRequest,
async onMutate(variables) {
await queryClient.cancelQueries({
queryKey: ['resource']
})
const previousData =
queryClient.getQueryData(['resource'])
queryClient.setQueryData(
['resource'],
old => {
return optimisticUpdate(old)
}
)
return { previousData }
},
onError(error, variables, context) {
queryClient.setQueryData(
['resource'],
context.previousData
)
},
onSettled() {
queryClient.invalidateQueries({
queryKey: ['resource']
})
}
})
Во время optimistic update именно cache управляет UI.
Не сервер.
Это важный архитектурный момент.
Интерфейс временно может не совпадать с backend.
Такое рассогласование считается допустимым.
Лучше всего работают операции:
Хуже подходят:
Полезно различать:
{
optimistic: true,
saving: true
}
<div
style={{
opacity: todo.saving ? 0.5 : 1
}}
>
{todo.title}
</div>
TanStack Query умеет автоматически повторять mutation.
retry: 3
Но вместе с optimistic update это требует осторожности.
Rollback может стать сложным.
Часто для optimistic mutation retry отключают:
retry: false
Или делают собственную стратегию retry.
Devtools TanStack Query особенно полезны для:
Во время optimistic update хорошо видно: