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
}
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)
}
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()
}
}
The engine owns all changes.
🧭 4. Validation Pipeline
enum ValidationState {
case valid
case invalid([FieldError])
}
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()
}
}
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)
}
}
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)
}
The UI becomes trivial.
⚠️ 8. Avoid Direct Bindings
Bad:
TextEditor(text: $document.title)
Good:
TextEditor(text: Binding(
get: { engine.document.title },
set: { engine.perform(UpdateTitleCommand($0)) }
))
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
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)