Оптимистичные обновления — механизм мгновенного изменения интерфейса до получения подтверждения от сервера. Пользователь изменяет форму, отправляет данные, а интерфейс сразу показывает успешный результат, создавая ощущение высокой скорости приложения.
В TanStack Query оптимистичные обновления чаще всего реализуются
через useMutation, queryClient.setQueryData и
обработчики жизненного цикла мутаций:
onMutateonErroronSuccessonSettledОсновная идея:
Формы — наиболее чувствительная часть интерфейса:
Без оптимистичных обновлений пользователь сталкивается с задержками:
Оптимистичный подход устраняет визуальную паузу между действием пользователя и изменением интерфейса.
const mutation = useMutation({
mutationFn: updateUser
})
Такой код не обновляет интерфейс до ответа сервера.
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: updateUser,
onMutate: async (newUserData) => {
await queryClient.cancelQueries({
queryKey: ['user']
})
const previousUser = queryClient.getQueryData(['user'])
queryClient.setQueryData(['user'], (old) => ({
...old,
...newUserData
}))
return { previousUser }
},
onError: (error, variables, context) => {
queryClient.setQueryData(
['user'],
context.previousUser
)
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ['user']
})
}
})
onMutateonMutate — центральная часть оптимистичных
обновлений.
Именно здесь:
cancelQueriesawait queryClient.cancelQueries({
queryKey: ['user']
})
Если не отменить активный refetch, возможна гонка состояний:
Отмена запросов предотвращает подобные конфликты.
const previousUser = queryClient.getQueryData(['user'])
Snapshot используется для rollback при ошибке.
return { previousUser }
Объект автоматически передается в:
onErroronSettledonError: (error, variables, context) => {
queryClient.setQueryData(
['user'],
context.previousUser
)
}
Если сервер вернул ошибку:
const { data: user } = useQuery({
queryKey: ['user'],
queryFn: fetchUser
})
function ProfileForm() {
const [name, setName] = useState(user.name)
const mutation = useMutation({
mutationFn: updateProfile,
onMutate: async (newData) => {
await queryClient.cancelQueries({
queryKey: ['user']
})
const previousUser =
queryClient.getQueryData(['user'])
queryClient.setQueryData(
['user'],
(old) => ({
...old,
...newData
})
)
return { previousUser }
},
onError: (error, variables, context) => {
queryClient.setQueryData(
['user'],
context.previousUser
)
}
})
const handleSubmit = (e) => {
e.preventDefault()
mutation.mutate({
name
})
}
}
После отправки:
Наиболее распространённый сценарий — создание записи.
const mutation = useMutation({
mutationFn: createTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({
queryKey: ['todos']
})
const previousTodos =
queryClient.getQueryData(['todos'])
queryClient.setQueryData(
['todos'],
(old = []) => [
...old,
{
id: Date.now(),
...newTodo,
pending: true
}
]
)
return { previousTodos }
},
onError: (error, variables, context) => {
queryClient.setQueryData(
['todos'],
context.previousTodos
)
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ['todos']
})
}
})
При создании сущностей сервер обычно генерирует ID самостоятельно.
До ответа сервера приходится использовать временные идентификаторы:
id: Date.now()
или:
id: crypto.randomUUID()
Полезно помечать оптимистичные записи:
pending: true
Это позволяет:
queryClient.setQueryData(
['todos'],
(old = []) =>
old.map((todo) =>
todo.id === updatedTodo.id
? {
...todo,
...updatedTodo
}
: todo
)
)
Удаление особенно выигрывает от мгновенного UI.
const mutation = useMutation({
mutationFn: deleteComment,
onMutate: async (commentId) => {
await queryClient.cancelQueries({
queryKey: ['comments']
})
const previousComments =
queryClient.getQueryData(['comments'])
queryClient.setQueryData(
['comments'],
(old = []) =>
old.filter(
(comment) => comment.id !== commentId
)
)
return { previousComments }
},
onError: (error, variables, context) => {
queryClient.setQueryData(
['comments'],
context.previousComments
)
}
})
Комментарий исчезает мгновенно, без ожидания сервера.
TanStack Query отлично сочетается с React Hook Form.
const {
register,
handleSubmit,
reset
} = useForm()
const mutation = useMutation({
mutationFn: createPost,
onSuccess: () => {
reset()
}
})
Иногда форма очищается до ответа сервера.
const onSub mit = (data) => {
mutation.mutate(data)
reset()
}
Такой подход создает ощущение мгновенного завершения операции.
Если запрос завершился ошибкой:
const onSub mit = (data) => {
cachedFormData.current = data
mutation.mutate(data)
reset()
}
onError: () => {
reset(cachedFormData.current)
}
Иногда форма влияет сразу на несколько частей кеша.
Например:
queryClient.setQueryData(
['users'],
updateUsers
)
queryClient.setQueryData(
['user', userId],
updateUser
)
queryClient.setQueryData(
['stats'],
updateStats
)
Переключатели — идеальный кандидат для optimistic UI.
const mutation = useMutation({
mutationFn: toggleLike,
onMutate: async () => {
await queryClient.cancelQueries({
queryKey: ['post', postId]
})
const previousPost =
queryClient.getQueryData([
'post',
postId
])
queryClient.setQueryData(
['post', postId],
(old) => ({
...old,
liked: !old.liked
})
)
return { previousPost }
},
onError: (error, variables, context) => {
queryClient.setQueryData(
['post', postId],
context.previousPost
)
}
})
После optimistic update часто возникает flickering:
Вместо полного invalidate:
onSuccess: (serverData) => {
queryClient.setQueryData(
['user'],
serverData
)
}
Так интерфейс остается стабильным.
mutationKeyconst mutation = useMutation({
mutationKey: ['update-user'],
mutationFn: updateUser
})
Это помогает:
Сложная проблема — несколько быстрых изменений подряд.
Например:
Запросы могут завершиться в другом порядке.
Если старый запрос завершится ошибкой позже нового успешного запроса:
Нельзя blindly откатывать весь объект.
Плохой подход:
queryClient.setQueryData(
['user'],
previousUser
)
queryClient.setQueryData(
['user'],
(current) => ({
...current,
name: context.previousName
})
)
Некоторые backend API возвращают:
{
"id": 1,
"name": "Alex",
"version": 15
}
Versioning помогает:
При пагинации обновление сложнее:
queryClient.setQueryData(
['posts'],
(oldData) => ({
...oldData,
pages: oldData.pages.map((page) => ({
...page,
items: page.items.map((item) =>
item.id === updatedPost.id
? updatedPost
: item
)
}))
})
)
Оптимистичное обновление должно учитывать текущие фильтры.
Пользователь меняет статус задачи:
status: 'completed'
Но текущий фильтр:
status === 'active'
Задача должна исчезнуть из списка сразу.
queryClient.setQueryData(
['todos', 'active'],
(old = []) =>
old.filter(
(todo) => todo.id !== updatedTodo.id
)
)
При наличии websocket возможны конфликты:
updatedAt
version
{
source: 'optimistic'
}
Иногда invalidateQueries слишком дорогой.
onSuccess: (serverTodo) => {
queryClient.setQueryData(
['todos'],
(old = []) =>
old.map((todo) =>
todo.tempId === serverTodo.tempId
? serverTodo
: todo
)
)
}
Такой подход:
Rollback должен сопровождаться визуальной обратной связью.
onError: () => {
toast.error(
'Не удалось сохранить изменения'
)
}
Во время optimistic update пользователь может повторно отправить форму.
isPending<button disabled={mutation.isPending}>
Сохранить
</button>
Иногда нет смысла обновлять весь объект.
queryClient.setQueryData(
['settings'],
(old) => ({
...old,
theme: 'dark'
})
)
TanStack Query поддерживает сценарии offline-first.
Оптимистичные обновления особенно важны при нестабильном интернете.
Для offline-first часто используется:
persistQueryClient
Это позволяет:
onMutate: () => {
queryClient.setQueryData(...)
}
Без onError кеш может навсегда остаться в неверном
состоянии.
queryClient.invalidateQueries()
Такой подход:
Опасный код:
{
...newData
}
Если сервер хранит дополнительные поля:
они могут исчезнуть из кеша.
Без cancelQueries optimistic update часто становится
нестабильным.
Полезно выносить optimistic update в отдельные функции:
function optimisticUpdateUser(
queryClient,
newUser
) {
queryClient.setQueryData(
['user'],
(old) => ({
...old,
...newUser
})
)
}
function rollbackUser(
queryClient,
previousUser
) {
queryClient.setQueryData(
['user'],
previousUser
)
}
const userKeys = {
all: ['users'],
detail: (id) => ['users', id]
}
Это уменьшает количество ошибок в optimistic updates.
Не каждое действие безопасно обновлять оптимистично.
Проблемные сценарии:
В подобных случаях предпочтительнее:
Наиболее удачные сценарии:
Наиболее опасные: