Skip to main content

undoMiddleware

Overview

The undo middleware allows you to keep a history of the changes performed to your data and travel back (undo) and forth (redo) between those changes.

For example, given this simple model:

@model("MyApp/Counter")
class Counter extends Model({ count: prop(0) }) {
@modelAction
add(n: number) {
this.count += n
}
}

const counter = new Counter({})

We can create an undo manager for it:

const undoManager = undoMiddleware(counter)

UndoManager

The returned undoManager offers the following data:

  • store: UndoStore - The store currently being used to store undo/redo action events.
  • undoQueue: ReadonlyArray<UndoEvent> - The undo stack, where the first operation to undo will be the last of the array.
  • redoQueue: ReadonlyArray<UndoEvent> - The redo stack, where the first operation to redo will be the last of the array.
  • undoLevels: number - The number of undo actions available.
  • canUndo: boolean - If undo can be performed (if there is at least one undo action available).
  • redoLevels: number - The number of redo actions available.
  • canRedo: boolean - If redo can be performed (if there is at least one redo action available).

And the following methods:

  • clearUndo() - Clears the undo queue.
  • clearRedo() - Clears the redo queue.
  • undo() - Undoes the last action. Will throw if there is no action to undo.
  • redo() - Redoes the previous action. Will throw if there is no action to redo.
  • withoutUndo(fn) - Runs a synchronous block without recording its changes in this manager.
  • withGroup(...) / withGroupFlow(...) - Groups several operations into one undo step.
  • createGroup(name?) - Creates a group that can span separate synchronous blocks.
  • dispose() - Disposes of the undo middleware.

UndoEvent

Each undo step is stored as an UndoEvent. A single action records an UndoSingleEvent with:

  • type: UndoEventType.Single - Identifies a single-action event.
  • targetPath: Path - Path to the object that invoked the action from its root.
  • actionName: string - Name of the action that was invoked.
  • patches: ReadonlyArray<Patch> - Patches with changes done inside the action. Use redo() in the UndoManager to apply them.
  • inversePatches: ReadonlyArray<Patch> - Patches to undo the changes done inside the action. Use undo() in the UndoManager to apply them.

A grouped step records an UndoEventGroup instead:

  • type: UndoEventType.Group - Identifies a grouped event.
  • groupName?: string - Optional name passed to the grouping API.
  • events: ReadonlyArray<UndoEventWithoutAttachedState> - The single or nested grouped events in this step.

Both forms include attachedState, with optional beforeEvent and afterEvent values when the middleware is configured to save attached state. Treat events and queues as read-only; use the UndoManager methods to manipulate history.

Storing the undo store inside your models

undoMiddleware accepts an optional UndoStore as its second argument. When omitted, it creates an in-memory store owned by the undo manager. Pass an UndoStore from your model tree when the queues themselves need to be snapshotted or persisted.

@model("MyApp/MyRootStore")
class MyRootStore extends Model({
undoData: prop<UndoStore>(() => new UndoStore({})),
counter: prop<Counter>(() => new Counter({})),
}) {}

const myRootStore = new MyRootStore({})

const undoManager = undoMiddleware(myRootStore, myRootStore.undoData)

Making some changes skip undo/redo

To exclude some changes from undo recording, use the manager's withoutUndo method or the global withoutUndo helper:

@modelAction
someAction() {
// this change will be redone/undone when the action is redone/undone
this.x++

// you may skip only in certain undo managers ...
someUndoManager.withoutUndo(() => {
// this one won't
this.y++
})

// or for all of them
withoutUndo(() => {
// this one won't
this.y++
})

// this one will
this.z++
}

Grouping multiple actions into a single undo/redo step

Sometimes you might want multiple actions to be undone/redone in a single step. If they are sync actions you may use the withGroup method like this:

someUndoManager.withGroup("optional group name", () => {
someModel.firstAction()
someOtherModel.secondAction()

// note how nested groups are allowed
someUndoManager.withGroup(() => {
someModel.thirdAction()
someOtherModel.fourthAction()
})
})

If they are async actions then you may use withGroupFlow instead:

someUndoManager.withGroupFlow("optional group name", function* () {
yield* _await(someModel.firstAsyncAction())

yield* _await(someService.someAsyncStuffInTheMiddle())

yield* _await(someModel.secondAsyncAction())
})

Another possibility is to use createGroup to group synchronous actions across separate code blocks:

const group = someUndoManager.createGroup("optional group name")
group.continue(() => {
someModel.firstSyncAction()
})

const asyncValue = await someService.someAsyncStuffInTheMiddle()

group.continue(() => {
someModel.secondSyncAction(asyncValue)
})
group.end() // at this point is when the undo event will be created

Now, once undo/redo is called all the actions will be undone/redone in a single call.

Limiting the number of undo/redo steps

By default there is no limit to the number of undo/redo steps that can be stored. If you want to limit the number of steps you can do so by passing the maxUndoLevels and maxRedoLevels options as third argument to undoMiddleware:

const undoManager = undoMiddleware(myRootStore, undefined, {
maxUndoLevels: 50, // or omit to have no limit
maxRedoLevels: 50, // or omit to have no limit
})

Adding attached state to each undo/redo step

Imagine a text editor where you don't want to undo each single cursor position change, but you still want to move the cursor to wherever it was before (when undoing) / after (when redoing) an action is performed.

For this use case you can use what is called an "attached state". This attached state gets saved before an undo/redo step is recorded, as well as after, and is restored after each undo/redo operation. In the case of the text editor, the "attached state" would be the cursor position.

interface TextEditorAttachedState {
cursorPosition: number
}

const undoManager = undoMiddleware(myRootStore, undefined, {
attachedState: {
save(): TextEditorAttachedState {
return {
cursorPosition, // get the cursor position
}
},
restore(attachedState: TextEditorAttachedState) {
// move the cursor position
},
},
})