DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI Editor Architecture (Text, Media, Forms at Scale)

Editors are the hardest category of apps to build.

Examples:

  • note apps
  • document editors
  • form builders
  • drawing tools
  • media annotators

They combine:

  • large mutable state
  • frequent updates
  • undo/redo
  • validation
  • autosave
  • performance constraints

This post shows how to design a scalable editor architecture in SwiftUI that stays:

  • fast
  • correct
  • testable
  • maintainable

🧠 The Core Principle

An editor is a state machine with commands.

If you mutate models directly, undo, autosave, and validation become impossible to reason about.


🧱 1. Document Model as the Single Source of Truth

struct Document {
    var title: String
    var blocks: [Block]
    var metadata: Metadata
}
Enter fullscreen mode Exit fullscreen mode

Never bind the UI directly to persistence.


🧬 2. Command-Based Mutations

Every edit becomes a command:

protocol EditorCommand {
    func apply(to doc: inout Document)
    func undo(on doc: inout Document)
}
Enter fullscreen mode Exit fullscreen mode

This enables:

  • undo/redo
  • change tracking
  • batching
  • autosave

🔁 3. Editor Engine

final class EditorEngine: ObservableObject {
    @Published private(set) var document: Document
    let undoStack = UndoStack()

    func perform(_ command: EditorCommand) {
        undoStack.perform(command)
        objectWillChange.send()
    }
}
Enter fullscreen mode Exit fullscreen mode

The engine owns all changes.


🧭 4. Validation Pipeline

enum ValidationState {
    case valid
    case invalid([FieldError])
}
Enter fullscreen mode Exit fullscreen mode

Validate:

  • on commit
  • on autosave
  • on publish

Never inline validation into views.


💾 5. Autosave System

func scheduleAutosave() {
    autosaveTask?.cancel()
    autosaveTask = Task {
        try await Task.sleep(nanoseconds: 2_000_000_000)
        await save()
    }
}
Enter fullscreen mode Exit fullscreen mode

Triggered by:

  • command execution
  • focus loss
  • background entry

🧠 6. Partial Rendering for Performance

Large documents must render lazily:

LazyVStack {
    ForEach(document.blocks) { block in
        BlockView(block: block)
    }
}
Enter fullscreen mode Exit fullscreen mode

Never render entire documents at once.


🧪 7. Testing Editor Behavior

Test commands, not UI:

func testInsertBlock() {
    var doc = Document(...)
    let cmd = InsertBlock(...)
    cmd.apply(to: &doc)

    XCTAssertEqual(doc.blocks.count, 2)
}
Enter fullscreen mode Exit fullscreen mode

The UI becomes trivial.


⚠️ 8. Avoid Direct Bindings

Bad:

TextEditor(text: $document.title)
Enter fullscreen mode Exit fullscreen mode

Good:

TextEditor(text: Binding(
    get: { engine.document.title },
    set: { engine.perform(UpdateTitleCommand($0)) }
))
Enter fullscreen mode Exit fullscreen mode

This enforces consistency.


❌ 9. Common Editor Anti-Patterns

Avoid:

  • direct state mutation
  • UI-driven persistence
  • no command layer
  • no undo
  • no validation
  • full document re-rendering

These don’t scale.


🧠 Mental Model

Think:

User Action
 → Command
   → Editor Engine
     → Document State
       → UI Render
Enter fullscreen mode Exit fullscreen mode

Not:

“The view changes the model”


🚀 Final Thoughts

Editor apps fail when:

  • state is mutated freely
  • undo is bolted on
  • performance is ignored

Command-driven editors:

  • scale
  • remain correct
  • are testable
  • feel professional

Top comments (0)