Перейти к содержанию
Innovialabs

Оптимистичные обновления интерфейса в React: паттерны и откат

Продвинутое4 мин

Коротко

Оптимистичный 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"] }),
});

Шаги:

  1. Отменить текущие запросы, чтобы старый refetch не перезаписал оптимистичные данные.
  2. Сохранить снимок кэша.
  3. Записать ожидаемый результат в кэш.
  4. При ошибке вернуть снимок и сообщить пользователю.
  5. В любом случае перезапросить данные, чтобы получить источник истины.

Вариант проще: не трогать кэш, а рендерить mutation.variables полупрозрачным элементом, пока isPending равно true. Откатывать нечего — при ошибке элемент просто исчезнет.

Гонки и дубли

Оптимистичный UI делает проблемы конкурентности заметнее. Типичные случаи:

ПроблемаЧто происходитКак решать
Устаревший refetchЗапрос, начатый до мутации, возвращает старые данныеcancelQueries перед обновлением
Параллельные мутацииОткат одной возвращает снимок, стирающий другуюОтменять только своё изменение (фильтр по id), а не весь снимок
Ответы не по порядкуОтвет на первый клик приходит после второгоОтправлять итоговое состояние («liked: true»), а не дельту; сравнивать версии
Ранняя инвалидацияRefetch после первой мутации стирает оптимистичные данные остальныхИнвалидировать, когда isMutating по ключу опустился до одного
Двойная отправкаПользователь кликает снова, ведь «уже сработало»id с клиента или idempotency key, дедупликация на сервере

ID, сгенерированные на клиенте, решают сразу две задачи: не нужно подменять временный id на настоящий, а повторный запрос с тем же id легко отсеять на бэкенде.

Где оптимистичный UI опасен

Не изображайте успех там, где ошибка имеет реальные последствия:

  • платежи, переводы, пополнение баланса — пользователь может закрыть вкладку, считая, что деньги ушли;
  • заказы с ограниченным остатком и бронирования, где доступность решает сервер;
  • необратимые действия: отправка письма, публикация, окончательное удаление;
  • операции со сложной серверной валидацией, исход которых нельзя предсказать.

Здесь нужно явное состояние ожидания: заблокированная кнопка, индикатор прогресса и экран подтверждения только после ответа сервера.

Частые ошибки

  • Нет сообщения об ошибке: элемент молча исчезает, и пользователь не понимает почему.
  • Откат всего кэша при параллельных мутациях с потерей чужих изменений.
  • Забытый финальный refetch — клиент и сервер расходятся.
  • Оптимистичное удаление важных данных без кнопки «Отменить».

Частые вопросы

useOptimistic — когда данные приходят через Server Components или локальное состояние и меняются через actions. Если серверные данные уже хранятся в кэше TanStack Query, обновляйте этот кэш в onMutate, чтобы все компоненты видели одно и то же состояние.

Поможем с этим

Если хотите не разбираться самим, а получить результат — этим мы занимаемся каждый день.

Обсудить проект

Читайте также

Есть идея или задача? Расскажите нам

Ответим в течение рабочего дня и предложим решение. Консультация бесплатная.