DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI Undo / Redo Architecture (Correct, Scalable, Testable)

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()
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

Undo history should reset when the feature exits.


🧭 5. ViewModels Emit Commands

Bad:

func renameItem() {
    item.name = newName
}
Enter fullscreen mode Exit fullscreen mode

Good:

func renameItem(to newName: String) {
    let command = RenameItemCommand(
        itemID: item.id,
        oldName: item.name,
        newName: newName,
        store: store
    )
    undoStack.perform(command)
}
Enter fullscreen mode Exit fullscreen mode

ViewModels describe intent.
Undo stack manages history.


🧩 6. Integrating with SwiftUI UI

Button("Undo") {
    viewModel.undoStack.undo()
}

Button("Redo") {
    viewModel.undoStack.redo()
}
Enter fullscreen mode Exit fullscreen mode

Keyboard shortcuts:

.commands {
    CommandGroup(after: .undoRedo) {
        Button("Undo") { viewModel.undoStack.undo() }
            .keyboardShortcut("z")

        Button("Redo") { viewModel.undoStack.redo() }
            .keyboardShortcut("Z", modifiers: [.command, .shift])
    }
}
Enter fullscreen mode Exit fullscreen mode

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() }
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

No UI. No hacks.


⚠️ 9. Memory & History Limits

Undo stacks must be bounded.

let maxHistory = 50
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)