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 EditActivityView: View {
    var activity: Activity
    @State private var text: String

    init(activity: Activity) {
        self.activity = activity
        self._text = State(initialValue: activity.name) // 🚩 Red Flag
    }

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

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(activity: Activity) {
    self.activity = activity
    self._text = State(initialValue: activity.name)
}

Why it breaks:

This creates a subtle, frustrating bug: activity.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 init.

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: $draftText)
    .focused($isFocused)
    .onAppear {
        draftText = activity.name
    }
    .onChange(of: activity.name) { _, newName in
        draftText = newName
    }
    .onChange(of: isFocused) { _, focused in
        if !focused {
            activity.name = draftText // 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

  1. The Save Button Race Condition: When a user taps a “Save” or “Add” button while the text field is still focused, a race occurs. If the button tap registers before the focus blur event completes execution, activity.name won’t contain the user’s latest typed input when your save logic runs.
  2. 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.
  3. 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 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 dedicated Presentation Item (e.g., FormDraft) that runs on its own isolated child context.

The Architecture: Pure Views, Zero Race Conditions

Let’s look at how this works in practice using a FormDraft managing an Author entity in Core Data or SwiftData:

1. The Presentation Model (FormDraft)

FormDraft owns a temporary child context and a reactive Combine pipeline. It handles trimming, model updates, and whole-entity validation synchronously as the user types.

import Combine
import CoreData

class FormDraft: Identifiable, ObservableObject {
    let context: NSManagedObjectContext
    let activity: Activity

    init(viewContext: NSManagedObjectContext) {
        let context = NSManagedObjectContext(childOf: viewContext)
        
        let author = Author(context: context)
        CFRunLoopRunInMode(.defaultMode, 0, false) // prevent erroneous first objectWillChange by context
        self.context = context
        self.activity = activity
        
        $draftName.map {
            let clean = $0.trimmingCharacters(in: .whitespacesAndNewlines)
            var valueToValidate: AnyObject? = clean as AnyObject?
            do {
                try activity.validateValue(&valueToValidate, forKey: "name")
                activity.name = clean
                return true
            } catch {
                return false
            }
        }
        .assign(to: &$isNameValid)
    }
    
    @Published var draftName = ""
    @Published private var isNameValid = false
    
    var isValid: Bool {
        isNameValid // && isOtherFieldValid
    }
    
    func save() throws {
        try context.save()
    }
}

2. The UI (FormContent & Presenting View)

Because FormDraft does the heavy lifting, 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 draft: FormDraft?. Tapping the “Edit” or “Add” button instantiates FormDraft right at the moment of interaction, passing it to .sheet(item:) or .alert(item:):

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

    var body: some View {
        
        Button("Add Author") {
            // Instantiate the draft context synchronously at the moment of interaction
            activeDraft = FormDraft(viewContext: viewContext)
        }  
        .sheet(item: $activeDraft) { draft in
            FormContent(item: draft)
        }
    }
}

struct FormContent: View {
    @ObservedObject var item: FormDraft
    @Environment(\.dismiss) private var dismiss
    
    var body: some View {
        Form {
            TextField("Author Name", text: $item.draftName)
            
            HStack {
                Button("Cancel", role: .cancel) {
                    dismiss() // Pure discard! Child context is garbage-collected.
                }
                
                Button("Save") {
                    try? item.save()
                    dismiss()
                }
                .disabled(!item.isValid)
            }
        }
    }
}

Because FormDraft does the heavy lifting, the SwiftUI view becomes a pure, side-effect-free function of state.

Why This Architecture Wins

Problem AreaTraditional @State / init SeedingPresentation-Scoped (FormDraft)
View CodeComplex (@FocusState.onChange.onAppear)Pure and minimal (Standard $item.draft bindings)
ValidationManual property checks or late save-time crashesInstant, real-time check (validateValue or validateForInsert for whole entity)
Race ConditionsFlaky (Save button vs. Focus Blur timing)Zero (Draft state is always up to date)
Cancel BehaviorComplex rollback / undo logic requiredFree (Discarding FormDraft deallocates the child context)
Memory SafetyState graph bloat / leaked values due to unexpected view identity & 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.

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