Skip to main content

Undo/Redo Manager

Overview​

The UndoManager provides automatic change tracking and time-travel capabilities for your mobx-bonsai node trees. It monitors changes using MobX's onDeepChange and groups changes within MobX actions into logical undoable events.

Note: If you're using the Y.js binding (mobx-bonsai-yjs) for collaborative editing, use Y.js's built-in UndoManager instead. Y.js's undo manager is specifically designed for CRDT operations and properly handles collaborative scenarios, including conflict resolution and remote changes. The mobx-bonsai UndoManager is intended for single-user, local undo/redo only!

Features​

  • ✅ Automatic Change Tracking: Monitors all changes to your node tree
  • ✅ Action Grouping: Changes within a single MobX action are grouped into one undoable event
  • ✅ Selective Recording: Disable recording for specific operations with withoutUndo()
  • ✅ Attached State: Save and restore external state alongside undo/redo operations
  • ✅ Queue Limits: Configure maximum undo/redo levels to manage memory

Basic Usage​

import { nodeType, UndoManager } from "mobx-bonsai"
import { runInAction } from "mobx"

// Define your node types
type TodoItem = {
text: string
completed: boolean
}

type TodoList = {
items: TodoItem[]
}

const TTodoItem = nodeType<TodoItem>().defaults({
text: () => "",
completed: () => false,
})

const TTodoList = nodeType<TodoList>().defaults({
items: () => [],
})

// Create your data
const todoList = TTodoList({
items: [
TTodoItem({ text: "Buy groceries", completed: false }),
TTodoItem({ text: "Write code", completed: false }),
],
})

// Create undo manager
const undoManager = new UndoManager({ rootNode: todoList })

// Make changes
runInAction(() => {
todoList.items[0].completed = true
})

// Undo the change
if (undoManager.canUndo) {
undoManager.undo()
}
console.log(todoList.items[0].completed) // false

// Redo the change
if (undoManager.canRedo) {
undoManager.redo()
}
console.log(todoList.items[0].completed) // true

// Clean up when done
undoManager.dispose()

Action Grouping​

Changes within a single MobX action are automatically grouped into a single undoable event:

// These three changes become a single undo event
runInAction(() => {
todoList.items[0].text = "Updated task"
todoList.items[0].completed = true
todoList.items[1].completed = true
})

// Single undo reverts all three changes
undoManager.undo()

This ensures that logical operations remain atomic from an undo/redo perspective.

Time-based Grouping (Debounce)​

By default, changes are only grouped within the same MobX action. However, you can enable time-based grouping to merge changes from different actions if they occur within a specified time window:

const undoManager = new UndoManager({
rootNode: todoList,
groupingDebounceMs: 500, // Group changes within 500ms
})

// These changes happen in separate actions but within 500ms
runInAction(() => {
todoList.items[0].text = "First change"
})

// 200ms later...
setTimeout(() => {
runInAction(() => {
todoList.items[0].completed = true // Grouped with previous change
})
}, 200)

// Single undo will revert both changes

This is particularly useful for:

  • Text input: Group rapid typing as a single undo event instead of character-by-character
  • Drag operations: Group position updates during dragging
  • Slider adjustments: Group value changes as the user drags
  • Auto-formatting: Group user input with automatic formatting changes

Note: If groupingDebounceMs is undefined (default), only MobX action-level grouping is used (the original behavior).

Selective Recording with withoutUndo()​

Sometimes you need to make changes that shouldn't be tracked in the undo history (e.g., auto-save timestamps, system-generated changes):

// This change won't be tracked
undoManager.withoutUndo(() => {
runInAction(() => {
todoList.lastSyncedAt = Date.now()
})
})

// Can return values
const result = undoManager.withoutUndo(() => {
runInAction(() => {
todoList.items.push(TTodoItem({ text: "New item", completed: false }))
})
return "Success"
})

// Can be nested with tracked changes in the same action
runInAction(() => {
todoList.items[0].completed = true // This will be tracked
undoManager.withoutUndo(() => {
todoList.lastModified = Date.now() // This won't be tracked
})
})

You can also check if recording is currently disabled:

if (undoManager.isUndoRecordingDisabled) {
// Recording is disabled
}

Queue Limits​

By default the max number of undo/redo levels is infinite, but you can limit it to manage memory in long-running applications:

const undoManager = new UndoManager({
rootNode: todoList,
maxUndoLevels: 50, // Keep last 50 undo events
maxRedoLevels: 50, // Keep last 50 redo events
})

// Check current levels
console.log(undoManager.undoLevels) // Number of available undo steps
console.log(undoManager.redoLevels) // Number of available redo steps

// Clear queues manually if needed
undoManager.clearUndo()
undoManager.clearRedo()

When the maximum is reached, the oldest events are automatically removed.

Attached State​

You can save and restore external state (not part of the node tree) alongside undo/redo operations:

// External state not in the node tree
const editorState = {
cursorPosition: 0,
selectionStart: 0,
selectionEnd: 0,
}

const undoManager = new UndoManager({
rootNode: document,
attachedState: {
save: () => ({
cursor: editorState.cursorPosition,
start: editorState.selectionStart,
end: editorState.selectionEnd,
}),
restore: (state) => {
editorState.cursorPosition = state.cursor
editorState.selectionStart = state.start
editorState.selectionEnd = state.end
},
},
})

// When you undo/redo, both the document and editor state are restored

This is useful for:

  • Cursor/selection positions in text editors
  • Scroll positions
  • UI state that's not part of the data model
  • Any external state that should be restored with undo/redo

Multiple Managers on Different Branches​

You can create multiple independent UndoManager instances that track different branches of the same root tree. Each manager maintains its own undo/redo history:

type Document = {
title: string
content: string
}

type RootStore = {
doc1: Document
doc2: Document
}

const TDocument = nodeType<Document>().defaults({
title: () => "",
content: () => "",
})

const TRootStore = nodeType<RootStore>().defaults({
doc1: () => TDocument({ title: "Doc 1", content: "" }),
doc2: () => TDocument({ title: "Doc 2", content: "" }),
})

const rootStore = TRootStore({
doc1: TDocument({ title: "Doc 1", content: "" }),
doc2: TDocument({ title: "Doc 2", content: "" }),
})

// Create separate managers for each document
const manager1 = new UndoManager({ rootNode: rootStore.doc1 })
const manager2 = new UndoManager({ rootNode: rootStore.doc2 })

// Changes to doc1 only tracked by manager1
runInAction(() => {
rootStore.doc1.title = "Updated Doc 1"
})

// Changes to doc2 only tracked by manager2
runInAction(() => {
rootStore.doc2.title = "Updated Doc 2"
})

// Each manager independently undoes its own changes
manager1.undo() // Only affects doc1
manager2.undo() // Only affects doc2

This is useful for:

  • Multi-document editors where each document has its own undo history
  • Split views with independent undo stacks
  • Modular applications with isolated state management

API Reference​

UndoManager​

Constructor Options​

interface UndoManagerOptions<S = unknown> {
// The subtree root to track changes on
rootNode: object

// Optional UndoStore to use (creates new one if not provided)
store?: UndoStore

// Maximum number of undo levels (default: Infinity)
maxUndoLevels?: number

// Maximum number of redo levels (default: Infinity)
maxRedoLevels?: number

// Time window in milliseconds for grouping changes (default: undefined)
// If undefined, changes are only grouped within the same MobX action
// If set, changes within this time window will be merged into one event
groupingDebounceMs?: number

// Attached state management
attachedState?: {
save(): S
restore(state: S): void
}
}

Properties​

  • canUndo: boolean - Whether undo is possible
  • canRedo: boolean - Whether redo is possible
  • undoLevels: number - Number of undo steps available
  • redoLevels: number - Number of redo steps available
  • undoQueue: ReadonlyArray<UndoEvent> - The undo event queue (read-only)
  • redoQueue: ReadonlyArray<UndoEvent> - The redo event queue (read-only)
  • isUndoRecordingDisabled: boolean - Whether recording is currently disabled
  • rootNode: object - The tracked root node
  • store: UndoStore - The underlying undo store

Methods​

  • undo(): void - Undoes the last change
    • Throws if called inside a MobX action
    • Throws if there's nothing to undo
  • redo(): void - Redoes the last undone change
    • Throws if called inside a MobX action
    • Throws if there's nothing to redo
  • clearUndo(): void - Clears the undo queue
  • clearRedo(): void - Clears the redo queue
  • withoutUndo<T>(fn: () => T): T - Executes a function without recording changes
    • Returns the function's return value
    • Can be nested
  • dispose(): void - Stops change tracking and cleans up
    • Safe to call multiple times

UndoStore​

The underlying store that holds undo/redo events. You can create a standalone store:

import { createUndoStore } from "mobx-bonsai"

const store = createUndoStore()

// Access the event queues
console.log(store.undoEvents) // Array of UndoEvent
console.log(store.redoEvents) // Array of UndoEvent

Best Practices​

  1. Create one UndoManager per document/root: Each logical document or editing context should have its own undo manager.

  2. Always dispose when done: Call dispose() to prevent memory leaks, especially in React components:

    useEffect(() => {
    const manager = new UndoManager({ rootNode: document })
    return () => manager.dispose()
    }, [document])
  3. Use action grouping wisely: Wrap related changes in runInAction() to create logical undo units:

    // Good: All related changes in one action
    runInAction(() => {
    todo.text = "New text"
    todo.completed = true
    todo.priority = "high"
    })

    // Avoid: Separate actions create separate undo events
    runInAction(() => { todo.text = "New text" })
    runInAction(() => { todo.completed = true })
    runInAction(() => { todo.priority = "high" })
  4. Set appropriate limits: Use maxUndoLevels and maxRedoLevels for long-running applications to prevent unbounded memory growth.

  5. Never undo/redo inside actions: The manager will throw an error if you try this, as it could lead to inconsistent state.

  6. Use withoutUndo for system changes: Auto-generated or system changes shouldn't create undo events:

    // Good: Don't track auto-save timestamps
    undoManager.withoutUndo(() => {
    runInAction(() => {
    document.lastSaved = Date.now()
    })
    })
  7. Consider attached state carefully: Only use attached state for truly external state. If it can be part of your node tree, it probably should be.

Limitations​

  • Cannot undo/redo inside MobX actions: This is enforced to prevent state inconsistencies
  • Changes outside actions: While supported, changes outside MobX actions create individual undo events for each change, which may not be ideal
  • Performance: Each change creates snapshot copies of the value being changed, which may impact performance for very large trees with frequent changes
  • Y.js Integration: When using the Y.js binding (mobx-bonsai-yjs), it's recommended to use Y.js's built-in UndoManager instead of mobx-bonsai's UndoManager. Y.js's undo manager is specifically designed to work with CRDT operations and handles collaborative editing scenarios correctly, including conflict resolution and remote changes