Оптимистичные обновления интерфейса в React: паттерны и откат
Продвинутое4 минРедакция Innovialabs
Коротко
Оптимистичный UI сразу показывает ожидаемый результат и синхронизируется с сервером в фоне; ему нужны снимок для отката, защита от гонок и дублей, и его нельзя применять там, где ложный «успех» стоит денег.
Что такое оптимистичный UI и когда он оправдан
Оптимистичное обновление — это изменение интерфейса так, будто запрос уже прошёл успешно, с подтверждением или откатом после ответа сервера. Лайк краснеет мгновенно, задача появляется в списке без спиннера, переименованный файл сразу показывает новое имя.
Подход хорошо работает, если выполнены три условия:
- операция почти всегда успешна (валидация уже прошла на клиенте);
- результат легко предсказать по входным данным;
- ошибку дёшево откатить визуально, и она не вводит пользователя в заблуждение о чём-то важном.
Если хотя бы одно не выполняется — показывайте честное состояние ожидания.
useOptimistic: встроенный подход React
В React 19 есть хук useOptimistic. Он возвращает временное состояние, которое существует только пока выполняется action или transition; после завершения React возвращается к реальному состоянию, которое вы передали.
import { useOptimistic } from "react";
function TodoList({ todos, addTodo }) {
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(current, draft) => [...current, { ...draft, pending: true }]
);
async function formAction(formData) {
const draft = { id: crypto.randomUUID(), text: formData.get("text") };
addOptimistic(draft);
try {
await addTodo(draft); // при успехе обязан обновить `todos` в родителе
} catch {
showToast("Не удалось сохранить задачу");
}
}
return (
<form action={formAction}>
<input name="text" required />
<ul>
{optimisticTodos.map((t) => (
<li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}>{t.text}</li>
))}
</ul>
</form>
);
}
Главное:
- Откат автоматический: если action упал, оптимистичный элемент исчезнет, как только transition завершится, — реальные
todosведь не менялись. - Обратная сторона: при успехе реальное состояние тоже должно обновиться (ревалидация,
setStateв родителе, Server Action сrevalidatePath). Иначе элемент пропадёт после завершения action. addOptimisticвызывается внутри action илиstartTransition, а не в обычном обработчике события.
TanStack Query: снимок, обновление, откат
Когда данные живут в кэше TanStack Query, классический паттерн строится на onMutate:
const queryClient = useQueryClient();
const addTodo = useMutation({
mutationFn: (todo: Todo) => api.addTodo(todo),
onMutate: async (todo) => {
await queryClient.cancelQueries({ queryKey: ["todos"] });
const previous = queryClient.getQueryData<Todo[]>(["todos"]);
queryClient.setQueryData<Todo[]>(["todos"], (old = []) => [...old, todo]);
return { previous };
},
onError: (_err, _todo, ctx) => {
queryClient.setQueryData(["todos"], ctx?.previous);
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
});
Шаги:
- Отменить текущие запросы, чтобы старый refetch не перезаписал оптимистичные данные.
- Сохранить снимок кэша.
- Записать ожидаемый результат в кэш.
- При ошибке вернуть снимок и сообщить пользователю.
- В любом случае перезапросить данные, чтобы получить источник истины.
Вариант проще: не трогать кэш, а рендерить mutation.variables полупрозрачным элементом, пока isPending равно true. Откатывать нечего — при ошибке элемент просто исчезнет.
Гонки и дубли
Оптимистичный UI делает проблемы конкурентности заметнее. Типичные случаи:
| Проблема | Что происходит | Как решать |
|---|---|---|
| Устаревший refetch | Запрос, начатый до мутации, возвращает старые данные | cancelQueries перед обновлением |
| Параллельные мутации | Откат одной возвращает снимок, стирающий другую | Отменять только своё изменение (фильтр по id), а не весь снимок |
| Ответы не по порядку | Ответ на первый клик приходит после второго | Отправлять итоговое состояние («liked: true»), а не дельту; сравнивать версии |
| Ранняя инвалидация | Refetch после первой мутации стирает оптимистичные данные остальных | Инвалидировать, когда isMutating по ключу опустился до одного |
| Двойная отправка | Пользователь кликает снова, ведь «уже сработало» | id с клиента или idempotency key, дедупликация на сервере |
ID, сгенерированные на клиенте, решают сразу две задачи: не нужно подменять временный id на настоящий, а повторный запрос с тем же id легко отсеять на бэкенде.
Где оптимистичный UI опасен
Не изображайте успех там, где ошибка имеет реальные последствия:
- платежи, переводы, пополнение баланса — пользователь может закрыть вкладку, считая, что деньги ушли;
- заказы с ограниченным остатком и бронирования, где доступность решает сервер;
- необратимые действия: отправка письма, публикация, окончательное удаление;
- операции со сложной серверной валидацией, исход которых нельзя предсказать.
Здесь нужно явное состояние ожидания: заблокированная кнопка, индикатор прогресса и экран подтверждения только после ответа сервера.
Частые ошибки
- Нет сообщения об ошибке: элемент молча исчезает, и пользователь не понимает почему.
- Откат всего кэша при параллельных мутациях с потерей чужих изменений.
- Забытый финальный refetch — клиент и сервер расходятся.
- Оптимистичное удаление важных данных без кнопки «Отменить».