The Flaw in Apple’s EditorConfig Pattern: Why isPresented Doesn’t Belong in Your State Model

Published by malhal on

In the classic WWDC 2020 session “Data Essentials in SwiftUI” (around timestamp 4:18), Apple presented a core architectural pattern for managing complex form state: the EditorConfig struct.

The speaker highlights why wrapping form data into a value type is so powerful:

“EditorConfig can maintain invariants on its properties and be tested independently. And because EditorConfig is a value type, any change to a property of EditorConfig… is visible as a change to EditorConfig itself.”

The underlying philosophy—using Swift value types to encapsulate form state, maintain domain invariants, and allow isolated unit testing—is brilliant.

However, Apple’s implementation in sample apps (like Fruta or Food Truck) included a subtle architectural trap: they embedded the presentation state (isPresented: Bool) directly inside the data model.

// Apple's Classic RecipeEditorConfig
struct RecipeEditorConfig {
    var recipe = Recipe.emptyRecipe()
    var shouldSaveChanges = false
    var isPresented = false // ⚠️ The problem child
    
    mutating func presentAddRecipe() {
        recipe = Recipe.emptyRecipe()
        shouldSaveChanges = false
        isPresented = true
    }
    
    mutating func cancel() {
        shouldSaveChanges = false
        isPresented = false
    }
}

While this seemed convenient at the time, entangling UI presentation lifecycle with draft data stateviolates fundamental SwiftUI design principles.

1. State Leakage: The “Zombie Draft” Problem

When isPresented lives inside RecipeEditorConfig, setting isPresented = false dismisses the sheet. However, the RecipeEditorConfig struct instance itself stays alive inside @State in the parent view.

Because the parent view retains the struct instance, closing the sheet doesn’t destroy the draft data. If your presentAddRecipe() or reset methods forget to manually overwrite every single property when opening a new form, old values from the previous editing session bleed into the new session.

// If a developer adds a new field to Recipe...
struct Recipe {
    var title: String
    var prepTimeMinutes: Int
    var tags: [String] // Added in v2.0
}

// ...and forgets to reset it in mutating methods:
mutating func presentAddRecipe() {
    recipe = Recipe.emptyRecipe()
    // ⚠️ Forgot to reset `tags`! Previous user tags linger in memory.
    isPresented = true
}

By contrast, decoupling presentation state from your data model guarantees a fresh, unpolluted state using value re-initialization:

Swift

// Triggering a clean state via direct re-initialization:
editor = RecipeEditor(initialRecipe: currentRecipe)
isPresented = true

2. iPad Anchor Points and Popovers Break

On iOS, sheets float over the full screen. On iPadOS and macOS, presentation behavior is far more nuanced. .popover and contextual .sheet modifiers rely on the trigger view’s frame to draw origin arrows and animate fluidly out of buttons.

When you bundle isPresented into a central EditorConfig object, developers often end up attaching presentation modifiers higher up the view tree (like on the root NavigationStack or list).

This strips iPadOS of its native presentation context, causing popovers to pop out from the center of the screen instead of anchoring neatly to the triggering button.

By isolating isPresented as local view state, you attach presentation modifiers directly to the triggering Button:

struct EditRecipeButton: View {
    @Binding var recipe: Recipe
    @State private var editor = RecipeEditor()
    @State private var isPresented = false

    var body: some View {
        Button("Edit") {
            editor = RecipeEditor(recipe: recipe)
            isPresented = true
        }
        // Attached directly to the button for proper iPad origin anchoring
        .popover(isPresented: $isPresented) {
            RecipeEditorView(editor: $editor) { updatedRecipe in
                recipe = updatedRecipe
            }
        }
    }
}

3. Procedural vs. Declarative SwiftUI

Apple’s EditorConfig pattern forces you to interact with state using imperative action methods:

config.presentAddRecipe()
config.cancel()
config.done()

This models SwiftUI like an imperative state machine.

SwiftUI is designed around declarative value transitions. When you re-initialize a draft struct, you aren’t doing expensive heap allocations; you are replacing a lightweight stack value. SwiftUI’s engine compares the diff instantly and updates the UI accordingly.

A Better Architecture: Pure Data + Local Presentation

Here is how to combine WWDC 2020’s powerful draft validation concepts with modern SwiftUI presentation mechanics.

Step 1: The Pure Data State Struct

The editor struct holds only form data, baseline comparison data, and computed validation invariants. It knows nothing about isPresented.

struct PersonEditor {
    private let initialName: String
    var name: String = "" {
        didSet {
            // Invariant validation updated reactively on every keypress
            canSave = Person.isValid(name: name) && name != initialName
        }
    }
    
    private(set) var canSave = false
    
    init(initialName: String = "") {
        self.initialName = initialName
        self.name = initialName
        self.canSave = false
    }
}

Step 2: Reusable Sheet Container

The sheet container handles layout, dismissal environment actions, and saving callbacks:

struct PersonEditorSheet: View {
    let title: String
    @Binding var editor: PersonEditor
    let onSave: () -> Void
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            Form {
                TextField("Name", text: $editor.name)
                    .autocorrectionDisabled()
            }
            .navigationTitle(title)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") {
                        onSave()
                        dismiss()
                    }
                    .disabled(!editor.canSave)
                }
            }
        }
    }
}

Step 3: Anchored Trigger View

The trigger view manages its own UI presentation flag and re-initializes PersonEditor on tap to guarantee zero stale data:

struct EditPersonButton: View {
    @Binding var person: Person
    @State private var editor = PersonEditor()
    @State private var isPresented = false

    var body: some View {
        Button("Edit") {
            // Re-initialization guarantees a 100% clean draft
            editor = PersonEditor(initialName: person.name)
            isPresented = true
        }
        .popover(isPresented: $isPresented) {
            PersonEditorSheet(title: "Edit Person", editor: $editor) {
                person.name = editor.name
            }
            .frame(minWidth: 320, minHeight: 200)
        }
    }
}

Conclusion

Apple was completely right about value types for form state: keeping invariants local, isolating validation, and enabling pure unit testing without spinning up UI views is essential.

Where the pattern needed refinement was separating data state from UI lifecycle.

By stripping isPresented out of your editor models:

  1. You prevent subtle state leakage bugs.
  2. Your forms remain 100% unit-testable.
  3. iPad/macOS popovers anchor precisely where users expect them to.
Categories: SwiftUI