You’ve been here before. A user types in a text field, then something else happens in the UI and their input vanishes. You check your property wrappers. Everything looks fine. The data is there, then it isn’t.
Here’s the thing: it’s almost never a problem with @State or @StateObject. It’s a problem with view identity — a concept SwiftUI doesn’t surface explicitly but relies on constantly.
What View Identity Actually Means
SwiftUI needs to track which view is which across updates. When state changes, it rebuilds the view hierarchy — but it doesn’t throw everything away. It compares the new hierarchy to the old one to figure out what changed. For that comparison, it needs to know: is this the same view as before, or a different one?
Same view → state preserved. Different view → state destroyed.
That’s the whole game. State follows identity.
SwiftUI uses two types of identity. Structural identity is the default — SwiftUI figures out which view is which based on position in the hierarchy. The first Text in a VStack is one view, the second is another.
Explicit identity is when you tell SwiftUI directly: ForEach with identifiable data, the .id() modifier, or anything conforming to Identifiable.
Both can bite you.
The Conditional View Trap
This looks innocent:
struct ProfileView: View {
@State private var notes = ""
let isEditable: Bool
var body: some View {
if isEditable {
TextField("Notes", text: $notes)
.textFieldStyle(.roundedBorder)
} else {
TextField("Notes", text: $notes)
.textFieldStyle(.roundedBorder)
.disabled(true)
}
}
}
The user types something. Then isEditable flips to false. Their text disappears.
Why? Swift compiles if/else into a _ConditionalContent view with two branches. The TextField in the if branch has a different identity than the one in the else branch. Different branch, different view, state gone.
The fix — keep the view in a single position and vary its properties:
TextField("Notes", text: $notes)
.textFieldStyle(.roundedBorder)
.disabled(!isEditable)
No branching. SwiftUI recognizes it as the same view. State preserved.
Key insight: Different branches = different views. Move conditions inside modifiers, not around views.
The ForEach Index Trap
This causes chaos in lists:
struct TaskListView: View {
@State private var tasks = [
Task(title: "Buy groceries"),
Task(title: "Call mom"),
Task(title: "Finish report")
]
var body: some View {
List {
ForEach(tasks.indices, id: \.self) { index in
TaskRow(task: tasks[index])
}
.onDelete { tasks.remove(atOffsets: $0) }
}
}
}
struct TaskRow: View {
let task: Task
@State private var isComplete = false
var body: some View {
HStack {
Toggle("", isOn: $isComplete)
.labelsHidden()
Text(task.title)
}
}
}
User marks “Buy groceries” complete. Deletes it. Now “Call mom” shows as complete.
You told SwiftUI to identify views by index. The view at index 0 had isComplete = true. When you deleted the first item, “Call mom” moved to index 0. SwiftUI thinks it’s the same view — same index — so it keeps the state. But it’s showing different data.
The fix — stable identifiers that move with the data:
struct Task: Identifiable {
let id = UUID()
var title: String
}
ForEach(tasks) { task in
TaskRow(task: task)
}
Now each TaskRow is identified by UUID. Delete “Buy groceries” and its view dies with its state. “Call mom” keeps its identity and its correct state.
Key insight: Indices identify positions, not data. Use stable identifiers that travel with the data.
The .id() Modifier Trap
The .id() modifier explicitly sets a view’s identity:
MyView()
.id(someValue)
When someValue changes, SwiftUI destroys MyView and creates a new one. All state resets.
Sometimes intentional — forcing a scroll view to reset position:
ScrollView {
// content
}
.id(selectedCategory) // Resets scroll on category change
But it becomes a trap when the value changes unexpectedly. Maybe your API layer creates fresh objects on each fetch, giving them new IDs. Or worse:
Text("Hello")
.id(UUID()) // DO NOT DO THIS
This creates a new identity every render. SwiftUI destroys and recreates constantly.
Key insight: .id() is a reset button. Use it deliberately or not at all.
Debugging Identity Problems
When state disappears, ask: did the view’s identity change?
Check for conditional branches. Check ForEach identifiers. Check .id() modifiers with changing values. Check if a parent is being recreated.
Quick debugging technique:
MyView()
.onAppear { print("appeared") }
.onDisappear { print("disappeared") }
If you see “disappeared” then “appeared” unexpectedly, you found it.
The Mental Model
| Identity | State |
|---|---|
| Stable | Preserved |
| Changed | Destroyed |
Three habits:
-
Prefer modifiers over branches. Use
.opacity(condition ? 0 : 1)instead ofif condition { View() }. -
Use stable identifiers in ForEach. Conform models to
Identifiablewith real IDs. Never use indices for mutable collections. -
Treat
.id()as nuclear. Know exactly why you’re adding it.
Further Reading
- Demystify SwiftUI (WWDC21) — The definitive source on identity, lifetime, and dependencies.
- Structural identity in SwiftUI — Practical examples of structural identity gotchas.
- id(_:): Identifying SwiftUI Views — Deep dive on
.id()behavior.