Undo/Redo функциональность в приложениях, использующих TanStack
Query, строится не как встроенная возможность библиотеки, а как
надстройка над механизмами кэширования, мутаций и оптимистических
обновлений. Основная идея заключается в управлении историей состояний
кэша и способности воспроизводить или откатывать изменения через
QueryClient.
В TanStack Query состояние данных централизовано в кэше, управляемом
QueryClient. Любое изменение данных на клиенте, будь то
через setQueryData или через mutations,
потенциально может быть зафиксировано как точка истории.
Ключевой момент:
Таким образом, undo/redo превращается в управление стеком состояний кэша.
Базовая реализация требует хранения двух стеков:
Каждый snapshot обычно включает:
queryKey)data)Простейшая структура:
const history = {
undoStack: [],
redoStack: []
}
Snapshot:
{
queryKey: ['todos'],
previousData: [...],
nextData: [...],
timestamp: Date.now()
}
Важно фиксировать именно previousData перед изменением,
иначе откат невозможен без повторного запроса к серверу.
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-стек очищается, так как новая операция разветвляет историю.
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 берёт последнее состояние из 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 выполняет обратное действие:
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 фактически повторно применяет ранее отменённое изменение.
В реальных приложениях изменения часто затрагивают несколько ключей кэша одновременно. Например:
В этом случае snapshot должен содержать набор записей:
{
snapshots: [
{
queryKey: ['todos'],
data: [...]
},
{
queryKey: ['todo', id],
data: {...}
}
]
}
Undo должен атомарно восстановить все связанные ключи.
Сложность возникает при использовании invalidateQueries.
Инвалидация приводит к повторной загрузке данных с сервера, что может
перезаписать восстановленное состояние.
Поэтому undo/redo логика часто требует:
setQueryData вместо refetchskipInvalidationDuringHistoryRestoreПример подхода:
queryClient.setQueryData(['todos'], snapshot)
queryClient.invalidateQueries(['todos'], {
refetchType: 'none'
})
Каждая мутация должна рассматриваться как потенциальная точка истории.
TanStack Query предоставляет хуки:
onMutateonErroronSuccessНаиболее важный для 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 имеет ряд фундаментальных ограничений:
Особенно критична проблема конкурентных мутаций, когда несколько изменений происходят одновременно и история становится нелинейной.
Для снижения нагрузки применяются техники:
Пример хранения diff:
{
queryKey: ['todos'],
diff: {
added: [newTodo],
removed: [id]
}
}
При откате diff применяется в обратном порядке.
Если одновременно происходят:
то undo должен учитывать порядок:
Для решения используется:
{
transactionId: 'tx-1',
operations: [...]
}
Undo в клиенте не означает автоматический откат на сервере. Для согласованности требуется:
Пример компенсирующей операции:
mutationFn: rollbackTodoChange
На практике используется несколько подходов:
Полное сохранение состояния кэша
Сохранение операций
Snapshot для критических точек + diff для промежуточных шагов
Ключевой момент заключается в том, что любое изменение через
setQueryData автоматически триггерит обновление UI. Это
делает undo/redo почти мгновенным, без дополнительной синхронизации
компонентов.
Модель:
Undo/redo в TanStack Query можно рассматривать как:
Эта модель не является встроенной функциональностью библиотеки, но
естественно вытекает из её архитектуры и возможностей управления кэшем
через QueryClient.