optimistic updates — механизм оптимистичного обновления
данных, при котором интерфейс изменяется ещё до завершения
HTTP-запроса.
Пользователь нажимает кнопку, интерфейс мгновенно показывает результат, а серверный запрос выполняется параллельно. Если запрос завершается успешно — изменения остаются. Если возникает ошибка — состояние откатывается назад.
Подход используется для:
Без optimistic updates интерфейс часто выглядит медленным:
Оптимистичное обновление устраняет эту задержку.
Последовательность обычно выглядит так:
Основные части:
onMutatesetQueryDataonErrorinvalidateQueriesПример:
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({
queryKey: ['todos']
});
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(
['todos'],
(old = []) => {
return [...old, newTodo];
}
);
return { previousTodos };
},
onError: (error, newTodo, context) => {
queryClient.setQueryData(
['todos'],
context.previousTodos
);
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ['todos']
});
}
});
onMutate — ключевой элемент optimistic updates.
Именно здесь:
Пример:
onMutate: async (newTodo) => {
await queryClient.cancelQueries({
queryKey: ['todos']
});
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(
['todos'],
(old = []) => [...old, newTodo]
);
return { previousTodos };
}
Во время optimistic update может выполняться параллельный refetch.
Проблема:
cancelQueries предотвращает такую ситуацию.
await queryClient.cancelQueries({
queryKey: ['todos']
});
Это особенно важно при:
Rollback невозможен без сохранения старых данных.
Обычно используется:
const previousTodos =
queryClient.getQueryData(['todos']);
После этого snapshot возвращается через context.
return {
previousTodos
};
Главный механизм optimistic update:
queryClient.setQueryData(
['todos'],
(old = []) => [...old, newTodo]
);
setQueryData обновляет cache напрямую без запроса к
серверу.
Компоненты автоматически получают новые данные.
Если mutation завершилась ошибкой:
onError: (error, variables, context) => {
queryClient.setQueryData(
['todos'],
context.previousTodos
);
}
Интерфейс возвращается к предыдущему состоянию.
Даже после успешного optimistic update рекомендуется делать refetch.
Причины:
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ['todos']
});
}
const addTodoMutation = useMutation({
mutationFn: createTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({
queryKey: ['todos']
});
const previousTodos =
queryClient.getQueryData(['todos']);
queryClient.setQueryData(
['todos'],
(old = []) => {
return [
...old,
{
...newTodo,
id: Date.now(),
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: true
Это позволяет:
const deleteMutation = useMutation({
mutationFn: deleteTodo,
onMutate: async (todoId) => {
await queryClient.cancelQueries({
queryKey: ['todos']
});
const previousTodos =
queryClient.getQueryData(['todos']);
queryClient.setQueryData(
['todos'],
(old = []) => {
return old.filter(
todo => todo.id !== todoId
);
}
);
return { previousTodos };
},
onError: (error, todoId, context) => {
queryClient.setQueryData(
['todos'],
context.previousTodos
);
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ['todos']
});
}
});
const updateMutation = useMutation({
mutationFn: updateTodo,
onMutate: async (updatedTodo) => {
await queryClient.cancelQueries({
queryKey: ['todos']
});
const previousTodos =
queryClient.getQueryData(['todos']);
queryClient.setQueryData(
['todos'],
(old = []) => {
return old.map(todo => {
if (todo.id === updatedTodo.id) {
return {
...todo,
...updatedTodo
};
}
return todo;
});
}
);
return { previousTodos };
},
onError: (error, variables, context) => {
queryClient.setQueryData(
['todos'],
context.previousTodos
);
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ['todos']
});
}
});
const toggleMutation = useMutation({
mutationFn: toggleTodo,
onMutate: async (todoId) => {
await queryClient.cancelQueries({
queryKey: ['todos']
});
const previousTodos =
queryClient.getQueryData(['todos']);
queryClient.setQueryData(
['todos'],
(old = []) => {
return old.map(todo => {
if (todo.id === todoId) {
return {
...todo,
completed: !todo.completed
};
}
return todo;
});
}
);
return { previousTodos };
},
onError: (error, variables, context) => {
queryClient.setQueryData(
['todos'],
context.previousTodos
);
}
});
Иногда одна mutation влияет на несколько query.
Например:
queryClient.setQueryData(
['posts'],
updatePosts
);
queryClient.setQueryData(
['post', postId],
updateSinglePost
);
queryClient.setQueryData(
['stats'],
updateStats
);
Оптимистичные обновления становятся сложнее при:
Например:
['posts', page]
Mutation может затрагивать:
Структура infinite query:
{
pages: [],
pageParams: []
}
Обновление:
queryClient.setQueryData(
['feed'],
(oldData) => {
return {
...oldData,
pages: oldData.pages.map(page => {
return {
...page,
items: page.items.map(item => {
if (item.id === updated.id) {
return updated;
}
return item;
})
};
})
};
}
);
Оптимистичный UI может временно отличаться от сервера.
Причины:
Проблема:
Это может приводить к повреждению состояния.
Иногда frontend начинает повторять backend-логику:
Чем сложнее backend-логика — тем опаснее optimistic updates.
Наиболее безопасные сценарии:
Плохие сценарии:
Подход:
Плюсы:
Минусы:
Подход:
Плюсы:
Минусы:
Иногда сервер возвращает обновлённую сущность.
Тогда можно сразу синхронизировать cache:
onSuccess: (serverTodo) => {
queryClient.setQueryData(
['todo', serverTodo.id],
serverTodo
);
}
Иногда rollback не нужен.
Например:
onMutate: () => {
queryClient.setQueryData(...);
}
Без:
onError
variables содержат аргументы mutation.
onMutate: (variables) => {
console.log(variables);
}
Пример:
mutation.mutate({
id: 1,
title: 'New title'
});
context — объект, возвращённый из
onMutate.
return {
previousTodos
};
Доступ:
onError: (
error,
variables,
context
) => {
console.log(context.previousTodos);
}
Крупные проекты часто выносят optimistic-логику отдельно.
Пример:
function optimisticUpdateTodo(
queryClient,
updatedTodo
) {
queryClient.setQueryData(
['todos'],
(old = []) => {
return old.map(todo => {
if (todo.id === updatedTodo.id) {
return {
...todo,
...updatedTodo
};
}
return todo;
});
}
);
}
Типизация context:
type TodosContext = {
previousTodos: Todo[];
};
Использование:
useMutation<
Todo,
Error,
UpdateTodoInput,
TodosContext
>({
mutationFn: updateTodo
});
TanStack Query поддерживает offline-сценарии.
Optimistic updates особенно полезны при:
Интерфейс продолжает реагировать даже без мгновенного ответа сервера.
Если требуется:
то обычный refetch часто надёжнее.
Лучше обновлять:
Чем меньше область изменений — тем ниже риск ошибок.
Даже если кажется, что rollback не понадобится.
const previous =
queryClient.getQueryData(queryKey);
Даже при успешном optimistic update.
queryClient.invalidateQueries({
queryKey: ['todos']
});
Особенно при:
const queryClient = useQueryClient();
const updateTodoMutation = useMutation({
mutationFn: async (todo) => {
const response = await api.patch(
`/todos/${todo.id}`,
todo
);
return response.data;
},
onMutate: async (updatedTodo) => {
await queryClient.cancelQueries({
queryKey: ['todos']
});
const previousTodos =
queryClient.getQueryData(['todos']);
queryClient.setQueryData(
['todos'],
(old = []) => {
return old.map(todo => {
if (todo.id === updatedTodo.id) {
return {
...todo,
...updatedTodo,
optimistic: true
};
}
return todo;
});
}
);
return {
previousTodos
};
},
onError: (
error,
updatedTodo,
context
) => {
queryClient.setQueryData(
['todos'],
context.previousTodos
);
},
onSuccess: (serverTodo) => {
queryClient.setQueryData(
['todos'],
(old = []) => {
return old.map(todo => {
if (todo.id === serverTodo.id) {
return {
...serverTodo,
optimistic: false
};
}
return todo;
});
}
);
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ['todos']
});
}
});