Designed to Fail: How SwiftUI Encourages Broken Form Architecture

Published by malhal on

If you’ve ever built a form or an edit screen in SwiftUI, you’ve almost certainly written code that looks like this:

struct EditAuthorView: View {
    var author: Author
    @State private var name: String

    init(author: Author) {
        self.author = author
        self._name = State(initialValue: author.name) // 🚩 Red Flag
    }

    var body: some View {
        TextField("Author Name", text: $name)
    }
}

It looks clean. It feels completely intuitive. It’s even used in official Apple sample projects (like ActivityTextField in Wishlist: Planning travel in a SwiftUI app).

And for a long time, developers are none the wiser. The form renders, typing works, and on the surface, everything seems fine. But beneath the surface, this pattern is a ticking time bomb. As soon as your app grows—when background syncs happen, multi-field validation is added, or a user taps “Save” while a keyboard is active—a cascade of subtle bugs, stale states, and race conditions emerges.

Why does code straight from Apple cause so many headaches at scale? Because SwiftUI’s default view APIs encourage you to build forms upside down, violating the core law of declarative UI: Views are functions of state, not long-lived objects.

Here is why traditional form architecture breaks down in SwiftUI—and the presentation-scoped pattern that fixes it permanently.

The Three Stages of SwiftUI Form Grief

Every SwiftUI developer goes through three distinct stages when trying to solve the “Draft State” problem—editing data without immediately corrupting the underlying model before the user hits “Save”.

Stage 1: The @State(initialValue:) Trap

At first glance, passing model values into @State inside a custom init feels like standard component encapsulation from the Object-Oriented world.

init(author: Author) {
    self.author = author
    self._name = State(initialValue: author.name)
}

Why it breaks:

This creates a subtle, frustrating bug: author.name might no longer be the same value it was when the user tapped the button to show the form—or it might have updated in the parent context—leaving your draft text holding an outdated string from the moment the View struct was first initialized. Furthermore, SwiftUI views are frequently destroyed and recreated; relying on init to seed state breaks the moment view identity changes.

Stage 2: The .onAppear.onChange, and @FocusState Patchwork

Realizing that init seeding fails, developers usually move to Stage 2: keeping the draft in @State and stitching together reactive modifiers to keep things in sync.

TextField("Name", text: $draftName)
    .focused($isFocused)
    .onAppear {
        draftName = author.name
    }
    .onChange(of: author.name) { _, newName in
        draftName = newName
    }
    .onChange(of: isFocused) { _, focused in
        if !focused {
            author.name = draftName // Commit on blur
        }
    }

The .onChange(initial: true) Misstep

In iOS 17, Apple introduced .onChange(of: value, initial: true)—a modifier that runs its closure immediately upon view creation and whenever the value changes.

On paper, Apple added this to reduce the boilerplate of pairing .onAppear with .onChange. In practice, it acts as syntactic sugar for a flawed architecture. It tricks developers into thinking local view-state synchronization is the intended solution, rather than rethinking who actually owns the draft state.

Why Stage 2 Breaks Down:

  • The Save Button Race Condition: When a user taps a “Save” or “Add” button while the text field is still focused, a race condition occurs. If the button tap registers before the focus blur event completes execution, author.name won’t contain the user’s latest typed input when your save logic runs.
  • Imperative Side-Effects: .onAppear and .onChange run after the render pass completes. This can cause visual glitches, frame delays, or accidental state overwrites when views reappear in navigation stacks.
  • Cancellation Hell: Rolling back dirty changes when the user taps “Cancel” requires manual state restoration code. You end up writing complex “undo” logic just to undo typing that should never have hit the live model in the first place.

Stage 3: Presentation-Scoped Draft State (The Solution)

To fix this, we need to invert our thinking: Child views shouldn’t manage their own draft lifecycle.

The action that presents the form (e.g., tapping “Edit” or “Add Item”) should instantiate the draft state right at the moment of user interaction.

Instead of forcing a child View to manage local draft buffers and focus lifecycles, we create a light data wrapper—a PresentedItem—whose sole job is to hold the data required for the presentation (the child context and the draft entity).

The Architecture: Pure Views, Zero Race Conditions

Let’s look at how this works in practice when managing an Author entity in Core Data or SwiftData.

1. The Presentation Data Item (PresentedItem)

Rather than hosting heavy business or validation logic, PresentedItem serves strictly as an Identifiable data container. It binds the lifetime of a temporary child context directly to the draft entity:

import CoreData

class PresentedItem: Identifiable {
    let context: NSManagedObjectContext
    let author: Author

    init(context: NSManagedObjectContext, author: Author) {
        self.context = context
        self.author = author
    }
}

2. The UI (PresentedItem & Presenting View)

Because PresentedItem holds the draft context and entity cleanly at presentation time, the SwiftUI form becomes a pure, side-effect-free function of state.

To present the form, the parent view holds a simple optional @State private var presentedItem: PresentedItem?. Tapping “Add Author” instantiates PresentedItem right at the moment of interaction, passing it directly to .sheet(item:) or .alert(item:):

Swift

struct PresentingView: View {
    @Environment(\.managedObjectContext) private var viewContext
    @State private var presentedItem: PresentedItem?

    var body: some View {
        Button("Add Author") {
            // Create an isolated child context attached to the parent
            let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
            context.parent = viewContext
            let author = Author(context: childContext)
            presentedItem = PresentedItem(context: context, author: author)
        }  
        .sheet(item: $presentedItem) { item in
            AuthorFormSheet(author: item.author)
            .environment(\.managedObjectContext, item.context)
        }
    }
}

struct AuthorFormSheet: View {
    @ObservedObject var author: Author
    @Environment(\.managedObjectContext) private var context: NSManagedObjectContext
    @Environment(\.dismiss) private var dismiss
    
    var body: some View {
        Form {
            TextField("Author Name", text: $author.name)
            
            HStack {
                Button("Cancel", role: .cancel) {
                    dismiss() // Pure discard! Child context and draft are garbage-collected.
                }
                
                Button("Save") {
                    try? context.save() // Pushes changes to parent context
                    dismiss()
                }
                .disabled(author.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
            }
        }
    }
}

Why This Architecture Wins

Problem AreaTraditional @State / init SeedingPresentation-Scoped (PresentedItem)
View CodeComplex (@FocusState.onChange.onAppear)Pure and minimal (Direct bindings to child context model)
ValidationManual property copies or late save-time crashesInstant, real-time validation on the draft entity
Race ConditionsFlaky (Save button vs. Focus Blur timing)Zero (Text field directly mutates the draft context)
Cancel BehaviorComplex rollback / undo logic requiredFree (Discarding PresentedItem deallocates the child context)
Memory SafetyState graph bloat / leaked values due to view re-evaluationsClean lifecycle pinned explicitly to sheet/navigation presentation lifetime

Why Doesn’t Apple Show This Pattern in Demos?

If this pattern is so superior, why do WWDC presentations and Apple samples stick to State(initialValue:).onAppear, or .onChange(initial: true)?

  1. Brevity over Scale: Demo code is optimized to show off a new API in 20 lines of code on a single slide, not to handle edge-case data synchronization in production applications.
  2. The OO Hangover: Most developers coming from Object-Oriented backgrounds (UIKit, React, Android) default to thinking of Views as long-lived components that should own their internal state. SwiftUI APIs inadvertently cater to this bias instead of correcting it.

Summary

When building forms in SwiftUI:

  • Stop trying to force child views to initialize or synchronize their own @State buffers.
  • Don’t fall into the .onChange(initial: true) sync loop.
  • Decouple draft editing from your live models using temporary child contexts.
  • Let the action presenting the UI instantiate the draft state via a simple presentation data container.

By shifting your draft lifecycles into presentation items, you stop fighting SwiftUI’s render tree and start writing clean, predictable, declarative code.