Оптимистичные обновления

Оптимистичные обновления в RTK Query основаны на предположении, что сервер вернёт успешный результат, и позволяют мгновенно отражать изменения в интерфейсе до фактического ответа API. Механизм строится на управляемом изменении кэша и последующем подтверждении или откате состояния в зависимости от результата запроса.

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

Ключевые элементы механизма:

  • временное изменение кэша через updateQueryData
  • контроль жизненного цикла мутации через onQueryStarted
  • откат изменений при ошибке с использованием patchResult.undo()

Жизненный цикл мутации с оптимистичным обновлением

RTK Query предоставляет хук onQueryStarted, который позволяет перехватить момент старта запроса и синхронно изменить кэш.

Процесс выглядит следующим образом:

  1. выполняется мутация (например, добавление элемента)
  2. кэш обновляется немедленно
  3. выполняется HTTP-запрос
  4. при успехе изменения остаются
  5. при ошибке происходит откат

Базовая реализация через updateQueryData

Оптимистичное обновление чаще всего реализуется через api.util.updateQueryData.

const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: () => '/posts',
    }),

    addPost: builder.mutation({
      query: (newPost) => ({
        url: '/posts',
        method: 'POST',
        body: newPost,
      }),

      async onQueryStarted(newPost, { dispatch, queryFulfilled }) {
        const patchResult = dispatch(
          api.util.updateQueryData('getPosts', undefined, (draft) => {
            draft.push({
              id: 'temp-id',
              ...newPost,
            });
          })
        );

        try {
          const { data: createdPost } = await queryFulfilled;

          dispatch(
            api.util.updateQueryData('getPosts', undefined, (draft) => {
              const index = draft.findIndex((p) => p.id === 'temp-id');
              if (index !== -1) {
                draft[index] = createdPost;
              }
            })
          );
        } catch {
          patchResult.undo();
        }
      },
    }),
  }),
});

Механизм патчей и отката

updateQueryData возвращает объект patch, содержащий возможность отмены изменений через undo.

Это позволяет строить предсказуемую модель:

  • кэш изменён локально
  • результат запроса подтверждает изменения
  • ошибка возвращает состояние к исходному

Ключевая особенность заключается в том, что RTK Query использует immutable update внутри Immer, поэтому изменения описываются как мутация draft-состояния, но фактически остаются неизменяемыми.

Использование временных идентификаторов

При создании сущностей часто возникает проблема отсутствия ID до ответа сервера. Для решения используется временный идентификатор.

Типичный подход:

  • создаётся temp-id или UUID на клиенте
  • объект добавляется в кэш сразу
  • после ответа сервера происходит замена временного объекта на реальный
import { nanoid } from '@reduxjs/toolkit';

async onQueryStarted(newPost, { dispatch, queryFulfilled }) {
  const tempId = nanoid();

  const patchResult = dispatch(
    api.util.updateQueryData('getPosts', undefined, (draft) => {
      draft.push({ id: tempId, ...newPost });
    })
  );

  try {
    const { data } = await queryFulfilled;

    dispatch(
      api.util.updateQueryData('getPosts', undefined, (draft) => {
        const post = draft.find((p) => p.id === tempId);
        if (post) {
          Object.assign(post, data);
        }
      })
    );
  } catch {
    patchResult.undo();
  }
}

Сложные сценарии обновления кэша

Обновление списков с параметрами запроса

Если запрос зависит от аргументов (фильтры, пагинация), необходимо учитывать ключ кэша.

api.util.updateQueryData('getPosts', { page: 1 }, (draft) => {
  draft.items.unshift(newItem);
});

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

Синхронизация нескольких кэш-запросов

Одна мутация может влиять на несколько запросов одновременно:

  • список постов
  • детальная карточка
  • пользовательские избранные элементы

В таких случаях выполняется несколько updateQueryData подряд.

dispatch(api.util.updateQueryData('getPosts', undefined, (draft) => {
  draft.push(createdPost);
}));

dispatch(api.util.updateQueryData('getPostById', createdPost.id, () => {
  return createdPost;
}));

Откат состояния при ошибках

Откат обеспечивается через patchResult.undo(). Это критический механизм поддержания консистентности.

try {
  await queryFulfilled;
} catch {
  patchResult.undo();
}

Откат возвращает кэш в состояние до применения optimistic patch, что исключает необходимость ручного восстановления данных.

Интеграция с invalidation тегов

Оптимистичные обновления часто комбинируются с тегами providesTags и invalidatesTags.

Однако при агрессивном использовании optimistic update:

  • уменьшается потребность в повторных запросах
  • снижается нагрузка на сеть
  • повышается отзывчивость интерфейса

Пример конфликтного сценария:

  • optimistic update изменяет кэш
  • invalidation вызывает refetch
  • результат refetch перезаписывает локальные изменения

Для предотвращения используется:

  • точечное обновление через updateQueryData
  • ограничение автоматического refetch для конкретных кейсов

Обновление вложенных структур

При работе с вложенными данными используется мутация draft-объекта.

api.util.updateQueryData('getBoard', boardId, (draft) => {
  const column = draft.columns.find(c => c.id === columnId);
  column.cards.push(newCard);
});

Immer позволяет безопасно изменять глубокие структуры без ручного клонирования.

Оптимизация и минимизация перерисовок

Каждое изменение кэша вызывает уведомление подписчиков. Поэтому важно:

  • обновлять только необходимые части состояния
  • избегать массовых мутаций больших массивов
  • разделять query endpoints по логическим зонам данных

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

Конфликты данных между клиентом и сервером

Возможны ситуации расхождения:

  • сервер возвращает изменённую структуру
  • данные уже были изменены другим клиентом
  • оптимистичное состояние устаревает

Стратегии обработки:

  • перезапись серверным ответом
  • слияние данных через merge-логику
  • приоритет server state над client optimistic state

Использование transformResponse в связке с optimistic update

transformResponse может использоваться для нормализации данных до попадания в кэш, что упрощает дальнейшие optimistic операции.

transformResponse: (response) => {
  return response.items;
}

После нормализации обновление кэша становится предсказуемым и не зависит от структуры API.

Практика разделения optimistic и server state

Чёткое разделение:

  • optimistic state — временное, мгновенное отражение действий
  • server state — авторитетный источник данных

RTK Query не хранит отдельного слоя optimistic state, он реализуется через временные патчи кэша, что упрощает архитектуру и исключает дублирование состояния.

Обработка гонок запросов

При быстром последовательном выполнении мутаций возможны гонки:

  • несколько optimistic updates подряд
  • ответы сервера приходят в разном порядке

Решение:

  • использование уникальных идентификаторов операций
  • последовательное применение patch/undo
  • отказ от полного перезаписывания кэша

Сложные цепочки мутаций

В сценариях типа “перемещение карточки между колонками” требуется:

  • удалить из одной коллекции
  • добавить в другую
  • обновить порядок элементов
api.util.updateQueryData('getBoard', boardId, (draft) => {
  const from = draft.columns.find(c => c.id === fromId);
  const to = draft.columns.find(c => c.id === toId);

  const cardIndex = from.cards.findIndex(c => c.id === cardId);
  const [card] = from.cards.splice(cardIndex, 1);

  to.cards.push(card);
});

Такие операции полностью выполняются на уровне кэша до обращения к серверу.

Итоговая модель поведения optimistic updates

RTK Query формирует строгую и предсказуемую модель:

  • изменение кэша через updateQueryData
  • фиксация изменений до запроса
  • подтверждение через queryFulfilled
  • откат через undo при ошибке
  • синхронизация через server response при необходимости

Эта модель позволяет строить интерфейсы с мгновенной реакцией без усложнения глобального состояния приложения.