The Silent State Destruction Bug in SwiftData & SwiftUI (And the Model id Overriding Workaround)
If you are using SwiftData with SwiftUI, you likely rely on the default identity behavior provided by @Model and ForEach. You create a new instance, insert it into your ModelContext, render it in a list, and let SwiftUI handle the rest.
However, if your row views maintain local @State (such as active form inputs, animation toggles, or timer loops) or execute long-running .task modifiers, you may have noticed a subtle, frustrating bug: saving the database context completely resets your view state.
Here is a deep dive into why this state destruction occurs, why naive fixes like id: \.self result in fatal runtime crashes, and how overriding var id directly on your @Model class permanently solves the problem.
1. The Setup: A Simple Draft View
Consider a standard SwiftUI list rendering SwiftData models where each row manages its own internal state, such as a running timer task or a local interaction counter:
@Model
final class Item {
var name: String
init(name: String) { self.name = name }
}
struct ItemRow: View {
let item: Item
@State private var counter = 0
var body: some View {
HStack {
Text(item.name)
Spacer()
Text("Counter: \(counter)")
}
.task {
// A long-running local task
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(1))
counter += 1
}
}
}
}
When you instantiate a draft Item in memory, SwiftData assigns it a temporary PersistentIdentifier.
struct ItemListView: View {
@Query var items: [Item]
@Environment(\.modelContext) private var modelContext
var body: some View {
List {
// Implicitly uses `item.id` (PersistentIdentifier by default)
ForEach(items) { item in
ItemRow(item: item)
}
}
.toolbar {
Button("Save Context") {
try? modelContext.save()
}
}
}
}
Everything seems fine at first. The view renders, the .task fires, and the counter increments every second: 1, 2, 3, 4…
2. The Bug: How Context Saves Wipe @State
Now, tap “Save Context”.
The moment modelContext.save() executes, the following happens:
- SwiftData writes the pending draft row to disk.
- SwiftData promotes the item’s default
persistentModelIDidentifier from a temporary handle to a permanent database primary key. - Because
@Modelclasses conform to@Observable, mutating the default.idproperty emits an observation notification. ForEachobserves this change, re-evaluates the key path for that item, and sees that the default identifier value has shifted from.temporary(...)to.persistent(...).
The Core Architectural Trap
In SwiftUI, View Identity equals View Storage Lifetime. As established in SwiftUI’s structural identity rules:
If a view’s explicit identity (
id) changes, SwiftUI treats it as a completely new view. The old view storage is destroyed, its@Stateis wiped, and a brand-new view is constructed in its place.
Even though the underlying Item class instance is the exact same reference in memory, ForEach derives its identity from the default value returned by id (PersistentIdentifier).
Because PersistentIdentifier changes value on save, ForEach invalidates the existing row identity. The old ItemRow is torn down, its .task is cancelled, its @State var counter resets to 0, and a brand-new ItemRow is instantiated.
3. The Failed Attempt: The id: \.self Dictionary Crash
A natural first instinct to keep identity constant across saves is to anchor identity directly to the object instance itself using id: \.self:
// ⚠️ DANGER: Causes fatal dictionary collisions
ForEach(items, id: \.self) { item in
ItemRow(item: item)
}
This compiles because @Model instances conform to Hashable. However, mutating model properties while the list is rendered triggers a fatal crash:
Fatal error: Duplicate keys of type 'Item' were found in a Dictionary.
This usually means either that the type violates Hashable's requirements, or
that members of such a dictionary were mutated after insertion.
Why id: \.self Crashes
- Mutable Property Hashing: SwiftData models rely on mutable attributes (
var name, etc.) for their synthesizedHashableconformance. - Hash Table Corruption: SwiftUI stores rendered items inside internal graph lookup dictionaries (
SwiftUICore.ViewList). - The Mutation Fault: When a property on
Itemchanges while the object resides in a dictionary, its hash value shifts out from under the dictionary bucket. When SwiftUI attempts to re-index its layout node, the hash table experiences a bucket mismatch or collision, resulting in a fatal crash.
4. The Cleaner Fix: Overriding var id on the Model
Instead of cluttering every ForEach call site with explicit keypaths like ForEach(items, id: \.objectID), you can redefine var id directly inside your model class to return an ObjectIdentifier(self) pointer handle:
@Model
final class Item: Identifiable {
var name: String
// Overriding default Identifiable implementation with a stable pointer ID
var id: ObjectIdentifier {
ObjectIdentifier(self)
}
init(name: String) {
self.name = name
}
}
Now, your SwiftUI views remain standard, clean, and completely idiomatic:
struct ItemListView: View {
@Query var items: [Item]
var body: some View {
List {
// Implicitly calls `item.id`, which now safely returns ObjectIdentifier(self)
ForEach(items) { item in
ItemRow(item: item)
}
}
}
}
5. Why Overriding var id Works Seamlessly
By making var id return ObjectIdentifier(self):
1. Zero State Reset on Save
When modelContext.save() executes and SwiftData updates internal database state, the Item class instance remains at the exact same memory address in heap RAM.
Because item.id returns the instance pointer address, ForEach observes zero identity change on save. SwiftUI keeps the view node alive, local @State variables persist without resetting, and active .task modifiers continue ticking seamlessly in the background.
2. Immune to Dictionary Collision Crashes
ObjectIdentifier evaluates equality and hash values strictly on the pointer memory address. Mutating item.nameor any other stored attribute on the model has zero effect on the hash value. SwiftUI’s internal ViewList dictionaries remain stable regardless of property mutations.
3. Clean View Syntax
You don’t need to pass explicit keypaths or custom modifiers in your views. Standard ForEach(items) works out of the box while staying robust against state destruction bugs.
Architectural Summary
| Identity Approach | id Type | Invariant on Property Edit? | Invariant on Context Save? | Preserves @State on Save? | Clean Callsite Syntax? | Risk Factor |
|---|---|---|---|---|---|---|
Default @Model | PersistentIdentifier | Yes | No (Flips Temp → Perm) | No | Yes | Wipes local @State and restarts .task |
id: \.self | Reference Self (Hashable) | No | Yes | Yes | No | Fatal Crash(Duplicate keys in Dictionary) |
Overriding var id | ObjectIdentifier | Yes | Yes | Yes | Yes | Stable, Clean & Safe |
By overriding var id on your @Model classes, you align SwiftUI’s view lifecycle directly with memory instance longevity—preserving row state across database saves without dirtying your view code.