Diagnosing SwiftUI identity bugs: stable IDs, diffing, and ghost updates
How to identify and fix SwiftUI identity problems that cause stale rows, animation glitches, unnecessary rebuilds, and state attached to the wrong view.
On this page
SwiftUI identity bugs are rarely polite enough to announce themselves as identity bugs.
A row shows the wrong selection. A toggle moves to a different item after sorting. An animation runs on the wrong cell. A detail view keeps old state after the user opens another record. The team blames List, then blames diffing, then adds a few .id(UUID()) calls and accidentally turns a small bug into a view-rebuild confetti cannon.
The useful diagnosis starts with a simpler question: what does SwiftUI believe this thing is?
SwiftUI does not just render values. It tracks view identity over time so it can preserve state, compute diffs, animate changes, reuse platform cells, and decide whether an update belongs to an existing view or a new one. When the identity model is wrong, the UI may look haunted even though every individual view body is technically correct.
1. Separate data identity from view identity
Most bugs start because the code treats identity as a convenience for loops.
ForEach(tasks.indices, id: \.self) { index in
TaskRow(task: tasks[index])
}This can work until the collection changes shape. Insert an item at the top, delete a row, sort the list, or filter completed tasks, and the same index now refers to different data. SwiftUI preserves row state for identity 0, 1, 2, and so on. Your product thinks those rows are tasks. The framework thinks they are positions.
Those are not the same thing.
A task row should usually be identified by the logical task ID:
struct Task: Identifiable, Equatable {
let id: UUID
var title: String
var isDone: Bool
}
ForEach(tasks) { task in
TaskRow(task: task)
}That tells SwiftUI that the row's identity follows the task through reordering, insertion, deletion, and filtering.
Use positional identity only when the product meaning is positional:
- calendar grid cells
- fixed onboarding pages
- static settings rows
- placeholder skeleton slots
- layout positions that intentionally keep state by slot
For product data, position is usually not identity. It is just where the thing happened to be during this render pass.
2. Reproduce the bug as an identity timeline
Before changing code, write the sequence that breaks.
For example:
Given a list sorted by priority
And row 2 has local edit state
When a new high-priority task is inserted at the top
Then the local edit state appears on the wrong taskThat timeline points directly at the likely cause: state is attached to row position instead of task identity.
Another common sequence:
Given the detail screen is showing project A
When the user selects project B from the sidebar
Then the form still contains unsaved field state from project AThat points at detail-view identity. SwiftUI may be reusing the same detail view instance because the structural position did not change, while the product expects a new editing context for a different project.
Identity bugs are easier to fix once the failing sequence names:
- the data that should keep its identity
- the UI state that moved, stayed, or disappeared incorrectly
- the mutation that exposed the bug
- the identity SwiftUI is probably using today
- the identity the product actually needs
Do this before scattering .id() around. Random identity changes can hide the symptom by destroying useful state, which is the UI equivalent of fixing a smoke alarm by removing the building.
3. Avoid generated IDs in computed view data
A stable ID must be stable across renders.
This is a quiet foot-gun:
struct SearchResult: Identifiable {
let id = UUID()
let title: String
}
var results: [SearchResult] {
rawResults.map { SearchResult(title: $0.title) }
}Every time results is recomputed, every item receives a new identity. SwiftUI cannot track which item changed because, from its perspective, the entire collection has been replaced by strangers wearing the same titles.
The symptoms usually look like:
- rows losing local state
- scroll position jumping
- animations restarting
- expensive row bodies rebuilding more than expected
- selection disappearing after refresh
- matched geometry effects failing to match anything useful
Prefer identity from the source domain:
struct SearchResult: Identifiable {
let id: Article.ID
let title: String
let snippet: String
}
var results: [SearchResult] {
rawResults.map { article in
SearchResult(
id: article.id,
title: article.title,
snippet: article.snippet
)
}
}If the backend does not provide an ID, create one at the ingestion boundary and persist it. Do not create it inside body, a computed property feeding ForEach, or a mapper that runs on every update.
A good rule: if two renders represent the same product object, the ID should be the same without depending on timing, array position, display text, or the kindness of random number generation.
4. Do not use display values as identity
Using a title as identity is tempting because it is already there.
ForEach(projects, id: \.name) { project in
ProjectRow(project: project)
}This is only safe if name is genuinely unique and immutable for the lifetime of the item. Most product names are neither.
The bug arrives when:
- two projects have the same name
- the user renames an item
- localization changes displayed text
- formatting changes between app versions
- the same logical item appears in two filtered sections
A rename should update the label of an existing row. It should not destroy the row and create another one with a new identity unless that is exactly the desired product behavior.
Use a dedicated stable ID:
struct Project: Identifiable, Equatable {
let id: UUID
var name: String
var color: ProjectColor
}Then test the rename case explicitly. It is one of the fastest ways to catch identity drift:
@Test
func renamingProjectPreservesSelection() {
var model = ProjectListModel(projects: [.sample(id: projectID, name: "Old")])
model.select(projectID)
model.rename(projectID, to: "New")
#expect(model.selection == projectID)
}That is not a SwiftUI test yet. It is a product-state test. Good. The view can only preserve identity if the model gives it one worth preserving.
5. Know when .id() resets state
.id() is not a seasoning.
Applying .id(value) tells SwiftUI that the view has a specific identity. When the value changes, SwiftUI treats that subtree as a different view. That can be exactly what you want. It can also be a very efficient way to delete all local state and blame the framework afterward.
Useful reset:
EditorView(documentID: document.id)
.id(document.id)If the editor owns draft state that should restart for a different document, tying the subtree identity to document.id is reasonable.
Bad reset:
TaskListView(tasks: tasks)
.id(UUID())That creates a new identity on every render. SwiftUI loses the ability to preserve list state, diff updates, or animate changes meaningfully. It is not a fix. It is a controlled demolition with unclear permits.
Before adding .id(), decide which state should survive.
Use .id() when:
- a screen must reset when the selected domain object changes
- a loaded document needs a fresh editing lifecycle
- an animation should intentionally restart for a new logical item
- stale local state is more dangerous than losing continuity
Avoid .id() when:
- you are trying to force a view to update because the model is not observable correctly
- rows are reusing the wrong state because
ForEachidentity is wrong - the value changes frequently without product meaning
- performance is already suspicious
If the problem is observation, fix observation. If the problem is row identity, fix row identity. .id() can be a scalpel. It is usually used like a fire axe.
6. Treat local row state as suspect
Rows are views, not databases.
This is fragile:
struct TaskRow: View {
let task: Task
@State private var isExpanded = false
var body: some View {
DisclosureGroup(task.title, isExpanded: $isExpanded) {
TaskNotesView(task: task)
}
}
}It may be fine if expansion is purely visual and row identity is stable. It becomes risky when expansion, editing, selection, focus, or draft text should follow the task across filtering and navigation.
For product-relevant state, move ownership out of the row:
struct TaskListState {
var expandedTaskIDs: Set<Task.ID> = []
var editingTaskID: Task.ID?
}Then the row receives a binding derived from the task ID:
TaskRow(
task: task,
isExpanded: binding(
get: { state.expandedTaskIDs.contains(task.id) },
set: { isExpanded in
if isExpanded {
state.expandedTaskIDs.insert(task.id)
} else {
state.expandedTaskIDs.remove(task.id)
}
}
)
)The exact helper does not matter. The ownership does.
If the state should belong to the task, key it by task identity. If it should belong to the visible slot, keep it local. If it should belong to the screen, store it in the screen model. Most bugs come from never making that decision and letting SwiftUI preserve whatever happened to be local.
7. Make selection ID-based, not object-based
Selection often exposes identity mistakes because it crosses view boundaries.
Avoid storing the selected object as the source of truth:
@State private var selectedTask: Task?If the task is refreshed, reloaded, merged from sync, or recreated as a value, the selected object may no longer match the item in the current collection. The UI can show stale details, lose selection, or edit a copy nobody saves.
Prefer selection by stable ID:
@State private var selectedTaskID: Task.ID?
var selectedTask: Task? {
tasks.first { $0.id == selectedTaskID }
}This gives the screen a clear recovery path:
- if the item still exists, show it
- if it was deleted, clear selection or show a missing-state message
- if it is loading, show a loading state
- if permissions changed, show a blocked state
The detail view should receive an ID when it needs to resolve current data:
TaskDetailScreen(taskID: taskID)Passing full mutable values through navigation often creates stale detail bugs. Passing an ID forces the destination to ask the current model what is true now.
That is especially important with sync, background refreshes, and multi-window apps. The selected thing is not whatever object the row captured during the tap. It is the logical record the user intended to open.
8. Debug diffing with explicit probes
When a list behaves strangely, add temporary probes that show identity and lifecycle.
A small debug modifier is often enough:
struct IdentityProbe: ViewModifier {
let label: String
let id: AnyHashable
func body(content: Content) -> some View {
content
.onAppear { print("appear", label, id) }
.onDisappear { print("disappear", label, id) }
.onChange(of: id) { _, newValue in
print("id changed", label, newValue)
}
}
}Use it where identity should be stable:
TaskRow(task: task)
.modifier(IdentityProbe(label: task.title, id: task.id))Also log the IDs entering the ForEach:
let ids = tasks.map(\.id)
logger.debug("task list ids: \(ids)")You are looking for mismatches:
- IDs change after a harmless refresh
- duplicate IDs appear in one collection
- rows disappear and reappear during a rename
- state follows an index instead of an object
- the detail subtree keeps the same identity when the selected record changes
Do not leave print archaeology in production. Move useful diagnostics into structured logs or tests once the root cause is understood. The probe is a flashlight, not furniture.
9. Check duplicate IDs before blaming SwiftUI
Duplicate IDs create undefined-feeling UI because SwiftUI cannot map old and new elements cleanly.
A defensive assertion near the model boundary can save hours:
extension Array where Element: Identifiable {
func assertUniqueIDs(
file: StaticString = #fileID,
line: UInt = #line
) {
let ids = map(\.id)
let uniqueIDs = Set(ids)
assert(
ids.count == uniqueIDs.count,
"Duplicate IDs in collection",
file: file,
line: line
)
}
}For real code, the helper may need constraints around Hashable IDs and better diagnostics. The point is the practice: check identity quality before the data reaches the view.
Common duplicate-ID sources:
- temporary client IDs that collide with server IDs
- imported records without stable identifiers
- grouping the same object into multiple sections without section-scoped identity
- using
\.selffor non-unique values like strings - placeholder rows all using the same fixed ID
Sectioned lists deserve extra care. If the same item can appear in multiple sections, identity may need to include the section context:
enum SearchRowID: Hashable {
case recent(Task.ID)
case result(Task.ID)
}That tells SwiftUI these are two row presentations, even if they point to the same underlying task. The product may want one shared state or two separate row states. Again, make the decision explicit.
10. Keep Equatable and identity in their lanes
Identity answers: is this the same logical thing over time?
Equality answers: do these values compare the same right now?
Do not blur them.
Two versions of the same task can be unequal because the title changed. They should still have the same identity:
Task(id: taskID, title: "Old")
Task(id: taskID, title: "New")Two different tasks can be equal in displayed content. They should still have different identities:
Task(id: firstID, title: "Inbox")
Task(id: secondID, title: "Inbox")EquatableView, .equatable(), and equality-based optimizations can reduce unnecessary updates in specific places, but they do not fix bad identity. If a row is attached to the wrong logical item, making equality more clever will not save it. It may just make the wrongness update less often, which is a bold debugging strategy and not one I recommend.
Start with stable identity. Then use equality and observation to control update cost.
11. Test the hostile mutations
Identity bugs are easy to miss if tests only append items.
Add tests or previews that perform the mutations users actually trigger:
- insert at the top
- delete the selected row
- sort while a row has local state
- filter and unfilter the collection
- rename the selected object
- refresh data with the same IDs and new values
- replace temporary IDs with server IDs
- duplicate an item
- move an item across sections
- open detail A, then detail B, then return to A
For UI-level checks, keep the assertions user-visible:
Given Task A is expanded
When Task B is inserted above it
Then Task A remains expanded
And Task B is collapsedFor model-level checks, assert the state shape directly:
#expect(state.expandedTaskIDs == [taskA.id])
#expect(state.selection == taskA.id)This is where many identity issues become obvious without launching the app. If the model stores selection by index, expanded state by row offset, or drafts in an array parallel to the data, hostile mutations will expose it quickly.
Parallel arrays deserve suspicion by default. They are often index-based identity wearing a nicer shirt.
12. The production baseline
For SwiftUI screens with dynamic data, I want this baseline:
- domain models have stable IDs created at the right boundary
ForEachuses domain identity for product objects, not indices- generated UUIDs do not appear in computed view data
- selection is stored as an ID, not a stale object copy
- product-relevant row state is keyed by domain ID
.id()is used deliberately to reset a subtree, not to force updates- duplicate IDs are asserted before data reaches complex views
- rename, insert, delete, sort, and filter flows are tested
- detail screens define whether state survives a selected-object change
- debug probes are used to confirm identity before rewriting architecture
SwiftUI is usually not confused. It is following the identity information the app provided. When that information is positional, unstable, duplicated, or tied to display text, the framework preserves exactly the wrong thing with impressive consistency.
Give SwiftUI stable identity and clear ownership for local state, and most of the ghost updates disappear. The remaining bugs become much easier to reason about: a specific object, a specific state owner, and a specific mutation that broke the contract. That is a diagnosis, not a séance.