Designed to Fail: How SwiftUI Encourages Broken Form Architecture
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
- 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.namewon’t contain the user’s latest typed input when your save logic runs. - Imperative Side-Effects:
.onAppearand.onChangerun 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 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)
FormDraftFormDraft 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 Area | Traditional @State / init Seeding | Presentation-Scoped (FormDraft) |
| View Code | Complex (@FocusState, .onChange, .onAppear) | Pure and minimal (Standard $item.draft bindings) |
| Validation | Manual property checks or late save-time crashes | Instant, real-time check (validateValue or validateForInsert for whole entity) |
| Race Conditions | Flaky (Save button vs. Focus Blur timing) | Zero (Draft state is always up to date) |
| Cancel Behavior | Complex rollback / undo logic required | Free (Discarding FormDraft deallocates the child context) |
| Memory Safety | State graph bloat / leaked values due to unexpected view identity & re-evaluations | Clean 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)?
- 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.
- 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
@Statebuffers. - 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.