Undo/Redo
Implementing per-user undo stacks with Yjs UndoManager.
Overview
In collaborative apps, users expect undo to reverse their own changes, not collaborators’. Yjs UndoManager provides this through “tracked origins.”
Basic Setup
import { UndoManager } from 'yjs'
const undoManager = new UndoManager([content])
undoManager.undo()
undoManager.redo()
undoManager.undoStack.length > 0 // canUndo
undoManager.redoStack.length > 0 // canRedo
Tracked Origins
Without tracked origins, undo captures remote changes too:
// WRONG: captures everything
const undoManager = new UndoManager([content])
// CORRECT: only capture local changes
const undoManager = new UndoManager([content], {
trackedOrigins: new Set(['user-action'])
})
// Mark local changes
yDoc.transact(() => {
content.set('title', 'New Title')
}, 'user-action') // Tracked
// Remote changes have no origin — not tracked
With Editor Bindings
const binding = new QuillBinding(yText, quill, awareness)
const undoManager = new UndoManager(yText, {
trackedOrigins: new Set([binding])
})Scoping to Shared Types
// Only track changes to cells, rowOrder, colOrder
const undoManager = new UndoManager([cells, rowOrder, colOrder], {
trackedOrigins: new Set(['user-action'])
})
// Changes to other types (metadata) not tracked
Capturing Metadata
Restore cursor position after undo:
const undoManager = new UndoManager([content], {
trackedOrigins: new Set(['user-action']),
captureTransaction: (transaction) => ({
cursorPosition: getCursorPosition()
})
})
undoManager.on('stack-item-popped', (event) => {
if (event.stackItem.meta.cursorPosition) {
setCursorPosition(event.stackItem.meta.cursorPosition)
}
})Transaction Grouping
Each transaction becomes one undo item:
yDoc.transact(() => {
items.set('a', data1)
items.set('b', data2)
items.set('c', data3)
}, 'user-action')
undoManager.undo() // Reverts all three at once
For typing, use captureTimeout to group rapid changes:
const undoManager = new UndoManager([content], {
trackedOrigins: new Set([binding]),
captureTimeout: 500 // Group changes within 500ms
})Clear History
undoManager.clear() // Clear undo/redo stacks
undoManager.stopCapturing() // End current group, start new one
Stack Events
undoManager.on('stack-item-added', (event) => {
updateUndoButtons()
})
undoManager.on('stack-item-popped', (event) => {
// Restore metadata
updateUndoButtons()
})Common Mistakes
Forgetting trackedOrigins:
// WRONG
const undoManager = new UndoManager([content])
// CORRECT
const undoManager = new UndoManager([content], {
trackedOrigins: new Set(['user-action'])
})Inconsistent origins:
// WRONG: one tracked, one not
content.set('title', 'New') // Not tracked
yDoc.transact(() => metadata.set('time', now()), 'user-action') // Tracked
// CORRECT: consistent
yDoc.transact(() => {
content.set('title', 'New')
metadata.set('time', now())
}, 'user-action')See Also
- Transactions - How transactions affect undo grouping
- API Usage - Origin-related mistakes