Undo / Redo is one of those features that:
- sounds simple
- gets hacked in quickly
- becomes a nightmare later
Symptoms of bad undo systems:
- random behavior
- actions undo the wrong thing
- redo breaks after navigation
- impossible-to-test logic
- UI tightly coupled to state mutations
This post shows how to design a real undo/redo architecture in SwiftUI:
- predictable
- scalable
- editor-friendly
- testable
- decoupled from UI
This is how professional tools do it.
🧠 The Core Principle
Undo is not UI.
Undo is state history.
If undo logic lives in views, it is already broken.
🧱 1. Undo as Commands, Not State Snapshots
Bad approach:
- store entire state snapshots
- restore blindly
This is:
- memory heavy
- slow
- brittle
Correct approach:
- model commands
- each command knows how to undo itself
🧩 2. Define an Undoable Command
protocol UndoCommand {
func execute()
func undo()
}
Each user action becomes a command.
Example:
final class RenameItemCommand: UndoCommand {
private let itemID: UUID
private let oldName: String
private let newName: String
private let store: ItemStore
init(itemID: UUID, oldName: String, newName: String, store: ItemStore) {
self.itemID = itemID
self.oldName = oldName
self.newName = newName
self.store = store
}
func execute() {
store.rename(id: itemID, to: newName)
}
func undo() {
store.rename(id: itemID, to: oldName)
}
}
Undo logic lives with the action, not the UI.
🔁 3. Undo Manager (Your Own, Not UIKit’s)
UIKit’s UndoManager exists, but for SwiftUI apps at scale, a custom manager is often clearer.
final class UndoStack {
private var undoStack: [UndoCommand] = []
private var redoStack: [UndoCommand] = []
func perform(_ command: UndoCommand) {
command.execute()
undoStack.append(command)
redoStack.removeAll()
}
func undo() {
guard let command = undoStack.popLast() else { return }
command.undo()
redoStack.append(command)
}
func redo() {
guard let command = redoStack.popLast() else { return }
command.execute()
undoStack.append(command)
}
}
Simple. Predictable. Testable.
🧠 4. Where the Undo Stack Lives
Undo stacks should live at the feature level, not globally.
Good:
- document editor
- canvas
- form flow
Bad:
- entire app
- shared singleton
final class EditorViewModel: ObservableObject {
let undoStack = UndoStack()
}
Undo history should reset when the feature exits.
🧭 5. ViewModels Emit Commands
Bad:
func renameItem() {
item.name = newName
}
Good:
func renameItem(to newName: String) {
let command = RenameItemCommand(
itemID: item.id,
oldName: item.name,
newName: newName,
store: store
)
undoStack.perform(command)
}
ViewModels describe intent.
Undo stack manages history.
🧩 6. Integrating with SwiftUI UI
Button("Undo") {
viewModel.undoStack.undo()
}
Button("Redo") {
viewModel.undoStack.redo()
}
Keyboard shortcuts:
.commands {
CommandGroup(after: .undoRedo) {
Button("Undo") { viewModel.undoStack.undo() }
.keyboardShortcut("z")
Button("Redo") { viewModel.undoStack.redo() }
.keyboardShortcut("Z", modifiers: [.command, .shift])
}
}
No UIKit required.
🧠 7. Grouping Commands (Atomic Operations)
Some actions are multi-step.
Example:
- drag resize
- batch edit
- paste operation
Use command grouping:
final class CompositeCommand: UndoCommand {
private let commands: [UndoCommand]
init(commands: [UndoCommand]) {
self.commands = commands
}
func execute() {
commands.forEach { $0.execute() }
}
func undo() {
commands.reversed().forEach { $0.undo() }
}
}
Now complex operations undo cleanly.
🧪 8. Testing Undo Behavior
Undo architecture is incredibly testable:
func testRenameUndo() {
let store = ItemStore()
let undo = UndoStack()
let command = RenameItemCommand(
itemID: id,
oldName: "Old",
newName: "New",
store: store
)
undo.perform(command)
XCTAssertEqual(store.name(for: id), "New")
undo.undo()
XCTAssertEqual(store.name(for: id), "Old")
}
No UI. No hacks.
⚠️ 9. Memory & History Limits
Undo stacks must be bounded.
let maxHistory = 50
After exceeding:
- drop oldest commands
- or coalesce commands
Never let undo grow unbounded.
❌ 10. Common Undo Anti-Patterns
Avoid:
- storing entire app state snapshots
- undo logic in views
- undo in AppState
- mixing undo with business logic
- implicit undo behavior
- clearing redo stack incorrectly
Undo bugs destroy user trust instantly.
🧠 Mental Model
Think:
User Intent
→ Command
→ Execute
→ Push to History
→ Undo / Redo
Not:
“Reverse whatever just happened”
🚀 Final Thoughts
A correct undo system gives you:
- professional UX
- fearless experimentation
- trust from users
- powerful editor workflows
- clean architecture
Undo is not a bonus feature.
It’s a correctness feature.
Top comments (0)