Undo/Redo функциональность

Undo/Redo функциональность в приложениях, использующих TanStack Query, строится не как встроенная возможность библиотеки, а как надстройка над механизмами кэширования, мутаций и оптимистических обновлений. Основная идея заключается в управлении историей состояний кэша и способности воспроизводить или откатывать изменения через QueryClient.

В TanStack Query состояние данных централизовано в кэше, управляемом QueryClient. Любое изменение данных на клиенте, будь то через setQueryData или через mutations, потенциально может быть зафиксировано как точка истории.

Ключевой момент:

  • кэш является источником текущего UI-состояния
  • любое изменение кэша можно сериализовать как snapshot
  • snapshot можно использовать для восстановления предыдущего состояния

Таким образом, undo/redo превращается в управление стеком состояний кэша.

Снимки состояния и структура истории

Базовая реализация требует хранения двух стеков:

  • undoStack — история предыдущих состояний
  • redoStack — отменённые состояния

Каждый snapshot обычно включает:

  • ключ query (queryKey)
  • данные (data)
  • временную метку
  • тип операции (insert, update, delete)
  • опционально — метаданные мутации

Простейшая структура:

const history = {
  undoStack: [],
  redoStack: []
}

Snapshot:

{
  queryKey: ['todos'],
  previousData: [...],
  nextData: [...],
  timestamp: Date.now()
}

Важно фиксировать именно previousData перед изменением, иначе откат невозможен без повторного запроса к серверу.

Интеграция с QueryClient

TanStack Query предоставляет два ключевых метода:

  • queryClient.getQueryData(queryKey)
  • queryClient.setQueryData(queryKey, updater)

Именно они используются для создания undo/redo механизма.

Перед изменением данных сохраняется snapshot:

const previous = queryClient.getQueryData(['todos'])

history.undoStack.push({
  queryKey: ['todos'],
  previousData: previous
})

После этого выполняется обновление:

queryClient.setQueryData(['todos'], old => {
  return [...old, newTodo]
})

Redo-стек очищается, так как новая операция разветвляет историю.

Optimistic updates как фундамент undo

Undo/redo наиболее естественно работает в связке с optimistic updates. При выполнении мутации данные обновляются сразу, до ответа сервера.

Пример:

useMutation({
  mutationFn: addTodo,
  onMutate: async (newTodo) => {
    const previous = queryClient.getQueryData(['todos'])

    history.undoStack.push({
      queryKey: ['todos'],
      previousData: previous
    })

    queryClient.setQueryData(['todos'], old => [
      ...old,
      newTodo
    ])

    return { previous }
  }
})

Если операция отменяется, rollback осуществляется через сохранённый snapshot.

Реализация undo операции

Undo берёт последнее состояние из undoStack и восстанавливает его в кэш:

function undo(queryClient, history) {
  const last = history.undoStack.pop()
  if (!last) return

  const current = queryClient.getQueryData(last.queryKey)

  history.redoStack.push({
    queryKey: last.queryKey,
    previousData: current
  })

  queryClient.setQueryData(last.queryKey, last.previousData)
}

Здесь важно, что redo формируется из текущего состояния перед откатом.

Реализация redo операции

Redo выполняет обратное действие:

function redo(queryClient, history) {
  const next = history.redoStack.pop()
  if (!next) return

  const current = queryClient.getQueryData(next.queryKey)

  history.undoStack.push({
    queryKey: next.queryKey,
    previousData: current
  })

  queryClient.setQueryData(next.queryKey, next.previousData)
}

Redo фактически повторно применяет ранее отменённое изменение.

Работа с несколькими queryKey

В реальных приложениях изменения часто затрагивают несколько ключей кэша одновременно. Например:

  • список задач
  • детальная карточка задачи
  • статистика

В этом случае snapshot должен содержать набор записей:

{
  snapshots: [
    {
      queryKey: ['todos'],
      data: [...]
    },
    {
      queryKey: ['todo', id],
      data: {...}
    }
  ]
}

Undo должен атомарно восстановить все связанные ключи.

Инвалидация и побочные эффекты

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

Поэтому undo/redo логика часто требует:

  • временного отключения refetch
  • или использования setQueryData вместо refetch
  • или флага skipInvalidationDuringHistoryRestore

Пример подхода:

queryClient.setQueryData(['todos'], snapshot)
queryClient.invalidateQueries(['todos'], {
  refetchType: 'none'
})

Связь с мутациями

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

TanStack Query предоставляет хуки:

  • onMutate
  • onError
  • onSuccess

Наиболее важный для undo — onMutate, так как он фиксирует состояние до изменения.

onMutate: async (payload) => {
  const previous = queryClient.getQueryData(['todos'])

  history.undoStack.push({
    queryKey: ['todos'],
    previousData: previous
  })

  return { previous }
}

При ошибке можно автоматически откатывать:

onError: (err, variables, context) => {
  queryClient.setQueryData(['todos'], context.previous)
}

Ограничения подхода

Undo/redo через TanStack Query имеет ряд фундаментальных ограничений:

  • кэш не является полноценной event-sourced системой
  • отсутствует встроенная версия данных
  • нет гарантий консистентности при параллельных мутациях
  • refetch может перезаписать историю
  • большие данные делают snapshot дорогим по памяти

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

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

Для снижения нагрузки применяются техники:

  • хранение диффов вместо полного snapshot
  • ограничение глубины истории
  • нормализация данных (через entity-based cache)
  • компрессия массивов изменений

Пример хранения diff:

{
  queryKey: ['todos'],
  diff: {
    added: [newTodo],
    removed: [id]
  }
}

При откате diff применяется в обратном порядке.

Параллельные изменения и конфликт состояний

Если одновременно происходят:

  • optimistic update A
  • optimistic update B

то undo должен учитывать порядок:

  • стек становится линейным
  • возможна потеря контекста состояния между промежуточными шагами

Для решения используется:

  • группировка мутаций в транзакции
  • маркировка операции transactionId
  • откат по группе операций
{
  transactionId: 'tx-1',
  operations: [...]
}

Синхронизация с сервером после undo

Undo в клиенте не означает автоматический откат на сервере. Для согласованности требуется:

  • отправка компенсирующей мутации
  • либо повторная синхронизация через invalidateQueries
  • либо event-based модель (CQRS)

Пример компенсирующей операции:

mutationFn: rollbackTodoChange

Архитектурные паттерны

На практике используется несколько подходов:

1. Snapshot-based history

Полное сохранение состояния кэша

  • простота
  • высокая память

2. Command-based history

Сохранение операций

  • компактность
  • сложная логика применения

3. Hybrid model

Snapshot для критических точек + diff для промежуточных шагов

Связь с реактивностью TanStack Query

Ключевой момент заключается в том, что любое изменение через setQueryData автоматически триггерит обновление UI. Это делает undo/redo почти мгновенным, без дополнительной синхронизации компонентов.

Модель:

  • history изменяет cache
  • cache обновляет подписчиков
  • UI пересобирается автоматически

Итоговая модель поведения системы

Undo/redo в TanStack Query можно рассматривать как:

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

Эта модель не является встроенной функциональностью библиотеки, но естественно вытекает из её архитектуры и возможностей управления кэшем через QueryClient.