NSUndoManager on macOS: Action Groups, Redo, and Document State
Design dependable AppKit undo and redo with NSUndoManager groups, inverse operations, action names, document integration, and failure-safe state transitions.
Undo is not a time machine. NSUndoManager records inverse operations so a user can reverse a coherent edit and then redo it. The application remains responsible for defining the edit boundary, preserving enough old state to reverse the change, and keeping the document model consistent when actions are replayed. A menu item can say “Undo Typing” while the implementation silently loses data if registration happens after a partial mutation or captures a mutable object that later changes.
This guide focuses on AppKit and document-style macOS applications using Foundation’s undo manager. The framework manages groups and the undo/redo stacks; it does not automatically make arbitrary model mutations reversible, durable, or transactional. Treat each undo registration as part of the command that changes your model.
Register an inverse, not a snapshot of the UI
An undo operation should contain the smallest stable information needed to apply the inverse. For replacing a value, retain the old value and register an inverse that restores it. When the inverse executes, it should register the opposite operation, which is how redo is constructed. Keep the operation at the model or document layer where it can be tested without a window controller.
import Foundation
@MainActor
final class RenameModel {
private(set) var name: String
let undoManager: UndoManager
init(name: String, undoManager: UndoManager) {
self.name = name
self.undoManager = undoManager
}
func rename(to newName: String) {
guard newName != name else { return }
let oldName = name
name = newName
undoManager.registerUndo(withTarget: self) { model in
model.rename(to: oldName)
}
undoManager.setActionName("Rename")
}
}
The method is deliberately idempotent for equal names: a no-op should not create an undo entry. The example assumes the undo manager and model are used on the main actor, as is common for AppKit document UI; a data model with another concurrency design needs an explicit serialization boundary. Do not capture a view or a mutable selection object when a stable identifier and old value are sufficient. Closures can outlive the screen that registered them.
Inverse operations should validate their preconditions. If an object was deleted externally or a referenced record no longer exists, define whether undo fails, becomes a no-op, or reports a recoverable conflict. Do not silently mutate an unrelated object because an array index changed. Stable model identifiers are usually safer than positions in a collection that can be reordered.
Groups define what one Undo means
Undo managers group registrations. AppKit applications commonly receive automatic grouping around a run-loop cycle, which can coalesce several low-level registrations into one user-visible step. That convenience is not a substitute for meaningful command design. Typing a continuous run may be one action; importing a set of files may be one action; each keystroke or each internal property assignment may be too granular for the user’s mental model.
Use explicit groups when a logical operation spans synchronous model updates that must undo together. Balance beginUndoGrouping() with endUndoGrouping() in defer so error paths cannot leave the manager in an open group. Nested groups can be useful for composing commands, but they still need a clear outer user action. Do not hold a group open across arbitrary asynchronous work: unrelated edits may be registered into it, and the original operation may no longer be atomic from the user’s perspective.
func applyImport(_ records: [Record], undoManager: UndoManager) throws {
undoManager.beginUndoGrouping()
defer { undoManager.endUndoGrouping() }
for record in records {
try insert(record) // insert must register its inverse before returning
}
undoManager.setActionName("Import Records")
}
The example assumes insert either succeeds or leaves its own model mutation and undo registration in a consistent state. If the import can fail halfway, prevalidate the input or use a model transaction that can roll back the whole operation. Ending an undo group does not automatically roll back partial work. The undo stack is a history mechanism, not a database transaction manager.
Redo is built by the inverse of undo
When the user undoes an action, the operation that performs the undo should register the inverse needed to redo it. This reciprocal registration keeps the redo stack aligned with what actually happened. Avoid trying to manually mirror two independently maintained histories. A bug often appears when an undo closure mutates a model but uses a lower-level setter that does not register the reverse action.
Test the full cycle, not only one undo: apply the operation, undo it, redo it, and compare the complete model state after each step. Include multiple operations and verify that the order is reversed correctly. A sequence such as insert, rename, move may require each inverse to restore an earlier identifier/location/value in the correct order. If an inverse changes shared state, its redo closure should capture the exact state produced by that inverse rather than guessing from the current UI.
Action names are user-facing labels. Set one after the group has the intended meaning, and use localized product language if the application supports localization. The manager can use these names in Undo and Redo menu titles. Avoid labels such as “Update” that do not tell the user what will happen, and do not include sensitive document content in an action name that may be exposed in menus or logs.
NSDocument and window integration
NSDocument exposes an undo manager and participates in undo notifications so that document edits can update change state. Prefer the document’s manager for edits to that document rather than a global manager that mixes changes across open files. The window responder chain and menu validation determine which document’s undo manager is active for commands. Test two documents open at once, switching key windows, and closing a document with pending undo history.
When a document change registers undo, its edited state should be updated consistently. When the user undoes back to the saved state, the document framework can reflect that transition; custom models should not maintain a second dirty flag that disagrees with the document’s change tracking. If an application has non-document transient state, define whether it belongs on the undo stack. Cursor position, selection, and window layout generally have different persistence and undo semantics than document content.
Do not assume one undo manager per window if the underlying data is shared. Decide whether an action belongs to a document, a shared model, or an independent inspector, then route it through the correct manager and responder context. Undoing a shared-model operation from one window should not leave another open document’s UI stale; notify or observe model changes through the application’s normal state propagation path.
Coalescing, disabling, and memory limits
For high-frequency edits such as dragging or text input, consider coalescing registrations into a single meaningful action. groupsByEvent controls automatic event grouping, but changing it globally can affect unrelated controls. Explicitly group the drag’s updates and close the group on mouse-up, cancellation, view removal, and lost capture. Do not keep a registration per pointer event if the user expects one “Move Object” command.
Temporarily disable undo registration only for operations that truly should not be undoable, such as rebuilding derived caches from authoritative model data. Use a balanced disable/enable scope and do not disable while making user-visible document mutations. If registration is disabled during an inverse operation, redo may be lost. When the operation is a bulk replacement, it may be better to register a single inverse containing a compact immutable prior state than to suppress undo altogether.
Set a practical levelsOfUndo policy for large documents. Undo closures retain captured objects, so an unbounded stack can retain substantial memory or keep obsolete model objects alive. Prefer compact value snapshots, stable IDs, and explicit pruning when a document is reset or closed. Profile actual workloads before choosing a stack depth; the right limit depends on the cost and usefulness of reversing the application’s actions.
Failure paths and verification
Undo closures should not throw through UI command handling without a recovery plan. If the underlying operation can fail, validate before registration, make the inverse robust, and surface a conflict that leaves the model unchanged. For multi-step operations, either use a model transaction or register a compensating inverse as each step completes. Test cancellation and partial failure, not only successful edits.
Create focused tests for an ordinary edit, a no-op, grouped edits, nested groups, undo/redo alternation, action naming, closing and reopening a document, and edits made while two documents are open. Assert both data and the document’s edited status. Include object deletion or external changes if the model can be modified outside the current window. A reliable undo system is one where every menu command corresponds to a stable user-intent boundary and every inverse has a defined result.
Related:
- NSDocument on macOS: Lifecycle, Autosave, and Version Recovery
- AppKit Responder Chains: First Responder, Actions, and Keyboard Routing
Sources: