Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Optimistic UI в TanStack Query: как правильно готовить onMutate и onError

Тимофей Ищенко

Коротко: Разбираем реализацию Optimistic UI в TanStack Query на TypeScript: использование onMutate, onError, cancelQueries и безопасный откат кеша при ошибках.

Ожидание ответа сервера при каждом клике делает интерфейс вязким. Спиннеры на простых действиях — переключении чекбокса, добавлении реакции или отправке комментария — раздражают пользователей и снижают субъективную скорость работы приложения.

Паттерн Optimistic UI (оптимистичные обновления) решает эту проблему: интерфейс перерисовывается мгновенно, предполагая, что сетевой запрос завершится успешно. Если бэкенд возвращает ошибку, состояние автоматически откатывается назад. В библиотеке TanStack Query (React Query) для этого предусмотрен встроенный механизм на базе хука useMutation и его коллбеков onMutate, onError и onSettled.


Жизненный цикл мутации: где живут onMutate и onError

Чтобы реализовать предсказуемый Optimistic UI, важно понимать строгий порядок выполнения этапов мутации в TanStack Query:

  1. onMutate: срабатывает синхронно сразу после вызова mutate(...), еще до того, как сетевой запрос (mutationFn) фактически уйдет на сервер.
  2. mutationFn: выполняет асинхронный сетевой запрос (POST, PUT, PATCH, DELETE).
  3. onSuccess: вызывается при успешном ответе бэкенда (получает данные ответа, переданные аргументы и мутационный контекст).
  4. onError: вызывается, если промис mutationFn завершился ошибкой (получает объект ошибки, аргументы и контекст).
  5. onSettled: срабатывает всегда после завершения мутации независимо от результата (успех или ошибка), являясь аналогом блока finally.
mutate() 
  │
  ▼
[ onMutate ] ──(возвращает context)──┐
  │                                  │
  ▼                                  │
[ mutationFn ]                       │
  │                                  │
  ├─── Успех ──► [ onSuccess ] ◄─────┤ (получает context)
  │                                  │
  └─── Ошибка ─► [ onError ]   ◄─────┤ (получает context)
                   │                 │
                   ▼                 │
                 [ onSettled ] ◄─────┘ (получает context)

Ключевую роль здесь играет мутационный контекст (Mutation Context). Любое значение или объект, возвращенный из функции onMutate, библиотека передает третьим аргументом в коллбеки onError, onSuccess и onSettled. Это позволяет передавать слепок старого состояния для отката без использования глобальных переменных и внешних ссылок useRef.


Пошаговый алгоритм безопасного оптимистичного обновления

Качественное оптимистичное обновление в TanStack Query строится по четкому четырехэтапному алгоритму. Пропуск даже одного шага приводит к багам рассинхронизации или мерцанию интерфейса.

Шаг 1. Отмена исходящих запросов (cancelQueries)

Первое действие внутри onMutate — отменить все активные фоновые рефетчи для обновляемого ключа кеша:

await queryClient.cancelQueries({ queryKey: ['todos'] });

Если этого не сделать, фоновый GET-запрос, отправленный за миллисекунду до действия пользователя, может разрешиться уже после применения оптимистичных данных и перезаписать локальный UI устаревшим серверным ответом.

Шаг 2. Снятие снапшота текущего кеша (getQueryData)

До внесения изменений необходимо зафиксировать актуальное состояние кеша. Этот снапшот — страховка на случай сетевого сбоя или серверной валидации:

const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);

Шаг 3. Запись оптимистичных данных (setQueryData)

С помощью метода setQueryData кеш обновляется локально в обход сети:

queryClient.setQueryData<Todo[]>(['todos'], (old) => {
  if (!old) return [];
  return [...old, newOptimisticTodo];
});

Шаг 4. Возврат контекста

Возвращаем объект с сохраненным состоянием, который библиотека передаст в обработчик ошибок:

return { previousTodos };

Обработка ошибок и Rollback через onError

Если сервер отвечает статус-кодом 4xx или 5xx, либо соединение обрывается, TanStack Query вызывает onError. В этот момент необходимо применить сохраненный снапшот:

onError: (err, newTodo, context) => {
  if (context?.previousTodos) {
    queryClient.setQueryData(['todos'], context.previousTodos);
  }
}

Зачем нужен onSettled при наличии отката

Даже если откат отработал штатно, на практике всегда рекомендуется вызывать queryClient.invalidateQueries внутри onSettled.

Сервер остается единственным источником истины (single source of truth). Ресинхронизация гарантирует, что клиентское состояние на 100% совпадает с базой данных, устраняя расхождения, вызванные серверными триггерами, округлением дат или генерацией автоинкрементных полей.


Практический пример на TypeScript: добавление задачи в список

Ниже представлен типизированный кастомный хук для добавления задачи в список с полноценным Optimistic UI.

import { useMutation, useQueryClient } from '@tanstack/react-query';

export interface Todo {
  id: string;
  title: string;
  completed: boolean;
}

export interface NewTodoPayload {
  title: string;
}

interface MutationContext {
  previousTodos?: Todo[];
}

const createTodoOnServer = async (payload: NewTodoPayload): Promise<Todo> => {
  const response = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });

  if (!response.ok) {
    throw new Error('Не удалось создать задачу на сервере');
  }

  return response.json();
};

export const useAddTodo = () => {
  const queryClient = useQueryClient();
  const queryKey = ['todos'];

  return useMutation<Todo, Error, NewTodoPayload, MutationContext>({
    mutationFn: createTodoOnServer,

    // 1. Срабатывает ДО сетевого вызова
    onMutate: async (newTodoPayload) => {
      // Отменяем исходящие запросы по ключу
      await queryClient.cancelQueries({ queryKey });

      // Сохраняем слепок предыдущего состояния
      const previousTodos = queryClient.getQueryData<Todo[]>(queryKey);

      // Оптимистично добавляем элемент в кеш
      queryClient.setQueryData<Todo[]>(queryKey, (old = []) => [
        ...old,
        {
          id: `temp-${crypto.randomUUID()}`, // Временный клиентский ID
          title: newTodoPayload.title,
          completed: false,
        },
      ]);

      // Возвращаем контекст с предыдущими данными
      return { previousTodos };
    },

    // 2. Срабатывает ТОЛЬКО при ошибке
    onError: (error, variables, context) => {
      // Откатываем кеш к состоянию до мутации
      if (context?.previousTodos) {
        queryClient.setQueryData(queryKey, context.previousTodos);
      }
      console.error('Ошибка создания задачи:', error.message);
    },

    // 3. Срабатывает ВСЕГДА (успех или ошибка)
    onSettled: () => {
      // Синхронизируем кеш с бэкендом
      queryClient.invalidateQueries({ queryKey });
    },
  });
};

Частые грабли при реализации Optimistic UI

1. Пропуск вызова await queryClient.cancelQueries

Распространенная ошибка гонки запросов: если пользователь открыл страницу (отправился GET-запрос) и сразу нажал кнопку добавления (сработал onMutate), без предварительной отмены первый GET-запрос завершится чуть позже и перезапишет кеш, стерев оптимистичный элемент.

2. Прямая мутация исходного массива в setQueryData

Использование мутирующих методов (например, old.push(...)) нарушает иммутабельность состояния. Снапшот previousTodos начинает ссылаться на тот же самый мутированный массив, из-за чего откат в onError перестает работать. Всегда возвращайте новый массив: [...old, newItem].

3. Обработка временных идентификаторов

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

Для решения этой проблемы можно:

  • блокировать кнопки вторичных действий на карточке, пока id содержит префикс temp-;
  • либо точечно подменять временный элемент на серверный ответ внутри коллбека onSuccess.

4. Неправильная типизация дженерика useMutation

Хук useMutation принимает четыре типа параметров: useMutation<TData, TError, TVariables, TContext>. Если не типизировать четвертый параметр (TContext), TypeScript определит тип context в onError как unknown, что потребует небезопасных ручных приведений.


Когда Optimistic UI не нужен

Оптимистичные обновления подходят не для всех сценариев. Применяйте этот паттерн осознанно:

  • Где Optimistic UI идеален: социальные действия (лайки, закладки, подписки), переключение статусов в таск-менеджерах, добавление комментариев, локальная сортировка списков.
  • Где лучше использовать классические спиннеры: финансовые транзакции и оплата, оформление заказов, сложные многошаговые формы с комплексной валидацией, генерация тяжелых отчетов.

FAQ (Часто задаваемые вопросы)

Зачем вызывать cancelQueries, если мы всё равно перезаписываем кеш?

Чтобы предотвратить гонку запросов (race condition). Если в момент мутации в фоне выполняется GET-запрос списка, его ответ может прийти позже применения оптимистичного стейта и перезаписать кеш устаревшими данными.

Что делать с временным ID сущности, пока сервер не вернул настоящий?

Генерируйте временный идентификатор на клиенте через crypto.randomUUID(). После успешного завершения мутации инвалидация через onSettled автоматически заменит клиентский список на актуальный с настоящими серверными ID.

Обязательно ли вызывать invalidateQueries в onSettled, если откат уже сделан в onError?

Да, это рекомендованная практика. Сервер может возвращать вычисляемые поля, обновлять временные метки или применять триггеры базы данных. Инвалидация гарантирует полную консистентность клиентского состояния.

Можно ли передавать в контекст функцию отката вместо объекта данных?

Да. В onMutate можно вернуть объект с функцией rollback: () => queryClient.setQueryData(...) и вызывать context.rollback() внутри onError. Однако передача снапшота данных чаще делает код проще для чтения и типизации в TypeScript.

Как показать пользователю уведомление об ошибке после отката?

Внутри коллбека onError можно вызвать тост-уведомление (toast notification) из используемой UI-библиотеки, сообщив пользователю, что сервер отклонил запрос и состояние было возвращено к исходному.


Вывод

Реализация Optimistic UI в TanStack Query делает интерфейс приложения визуально мгновенным и отзывчивым. Надежный шаблон оптимистичного обновления строится на взаимодействии четырех компонентов:

  1. cancelQueries защищает от перезаписи данных параллельными фоновыми запросами.
  2. getQueryData фиксирует исходную точку возврата в Mutation Context.
  3. onError восстанавливает кеш из сохраненного контекста при сетевых или серверных сбоях.
  4. onSettled ресинхронизирует состояние с сервером, гарантируя точность данных.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

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