What this usually means
The root cause is that SwiftUI's dependency tracking didn't register the view as dependent on the changed piece of state. This happens when the view's identity (its stable identifier) changes, causing SwiftUI to treat it as a new view that never subscribed to the state. Alternatively, the state change might be happening on a background thread, and SwiftUI only observes main-actor updates. Another common cause: the view uses Equatable conformance that short-circuits the diffing, or the child view is created in a way that doesn't capture the binding correctly.
The first ten minutes — establish facts before touching code.
- 1Add a .onChange(of:) modifier on the state variable and put a breakpoint—does it fire? If not, the state isn't changing as you think.
- 2Use introspection: wrap the view in a ZStack and add a Text("\(stateVariable)") overlay to see the actual value at runtime.
- 3Check thread: In the breakpoint or a print statement, use Thread.isMainThread. If false, that's your smoking gun.
- 4Verify view identity: Add a unique id modifier (like .id(UUID())) to force recreation. If the view updates, the problem is identity-based.
- 5Simplify: Strip the view down to a single @State variable and a Button that toggles it. If that works, reintroduce complexity piece by piece.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchThe View struct's body property—check if it's using a computed property that creates a new instance each time
- searchThe ObservableObject class—are you updating @Published properties on a background queue?
- searchThe parent view that creates this view—does it pass an id parameter or rely on ForEach's id?
- searchAny .equatable() modifier on the view—this can prevent re-render if Equatable returns true incorrectly
- searchThe view hierarchy in the debugger (use the View Debugger in Xcode) to see if the view is being destroyed and recreated
- searchThe .onReceive or .onAppear blocks—they might be cancelling subscriptions or not setting up bindings correctly
Practical causes, not theory. These are the things you will actually find.
- warningUpdating @Published properties on a background DispatchQueue without using .receive(on: DispatchQueue.main)
- warningUsing ForEach with a non-identifiable data model and the wrong id parameter, causing identity mismatch
- warningApplying .equatable() to a view that compares based on a property that didn't actually change
- warningCreating a child view with a closure that captures @State by value instead of using a @Binding
- warningUsing a computed property in the body that returns a different view type on each access
- warningCalling a mutating function on a @State variable inside a method that's not marked as mutating—actually, @State is a struct so you must use the $ syntax or explicit reassignment
Concrete fix directions. Pick the one that matches your root cause.
- buildDispatch async updates to main queue: .receive(on: DispatchQueue.main) in Combine pipelines or await MainActor.run { ... } in async code
- buildEnsure ForEach uses stable ids: provide an id parameter that's unique and stable (like the object's ID), not an index
- buildRemove or correct .equatable() conformance: only conform if you truly want to prevent updates; otherwise let SwiftUI's default diffing work
- buildUse @Binding for child views that need to modify parent state—don't pass @State as a let property
- buildUse .id(UUID()) to force re-creation only as a last resort—this is a blunt instrument that loses state
A fix you cannot prove is a guess. Close the loop.
- verifiedAdd print statements in the View's body or a computed property—it should print every time the state changes
- verifiedUse the debugger to inspect the view hierarchy: the view should be highlighted as 'updated' when state changes
- verifiedEnable the SwiftUI instrument in Instruments: it shows view body evaluations and dependency tracking
- verifiedWrite a unit test that asserts the view's body is called after state change (using expectation and dispatch)
- verifiedRemove and re-add the view: if the problem goes away, the previous instance was stale
Things that make this bug worse or harder to find.
- warningDon't force unwrap optionals in the view's body—that can cause layout to fail silently and never update
- warningDon't use @ObservedObject if the object is created inside the view—use @StateObject instead
- warningDon't mutate @State directly from background threads—always use DispatchQueue.main.async
- warningDon't nest ForEach inside List without providing identifiers—SwiftUI will reuse cells incorrectly
- warningDon't put .onAppear inside a conditional view—it won't fire again if the view is recreated
User profile screen doesn't update after editing
Timeline
- 09:00User reports that after tapping 'Save' on profile edit, the changes don't appear on the profile screen.
- 09:10I check the view: ProfileView observes a ProfileViewModel via @ObservedObject.
- 09:20ProfileViewModel has @Published var user: User. The save function updates user.name.
- 09:30I add a print in the view's body—it prints only once on initial load.
- 09:45I check the save function: it calls an API and then updates user on a background queue.
- 10:00Fix: add .receive(on: DispatchQueue.main) on the API publisher.
- 10:05Verify: after fix, body prints on every save and UI updates.
- 10:10Deploy hotfix via TestFlight; user confirms fix.
The bug report came in at 9 AM. A user had edited their display name, hit Save, and the profile screen showed the old name. I reproduced it on my device: the API call succeeded, the User object in memory had the new name, but the screen was stuck. I've seen this pattern before—state change without UI update is almost always a thread issue or identity problem.
I opened the ProfileView. It used @ObservedObject var viewModel: ProfileViewModel. The viewModel had @Published var user: User. The save method called an API via URLSession.dataTaskPublisher, and in the sink completion, it updated self.user = updatedUser. But that sink was running on a background thread. SwiftUI only observes @Published changes on the main thread. The assignment happened off the main actor, so the view never got notified.
The fix was trivial: add .receive(on: DispatchQueue.main) to the Combine pipeline. I also added a .print() to verify the event. After the fix, the UI updated instantly. I shipped a hotfix through TestFlight. The lesson: always assume that network callbacks run on background threads unless you explicitly switch to main. I also added a MainActor.run wrapper in the async version of the same function to prevent it from happening again.
Root cause
Updating a @Published property on a background thread without switching to the main actor.
The fix
Add .receive(on: DispatchQueue.main) to the Combine publisher chain, or wrap the assignment in await MainActor.run.
The lesson
SwiftUI's state observation is tied to the main actor. Any mutation of @Published or @State must happen on the main thread or within MainActor.run.
SwiftUI uses view identity to track which view corresponds to which state. Each view has a stable identity that SwiftUI uses to match state changes to the correct view. If the identity changes between updates (e.g., because you used a non-unique id in ForEach or a conditional that creates a new view struct), SwiftUI treats it as a brand new view that never subscribed to the old state.
The most common identity bug is using the index of a ForEach as the id. When the array changes, indices shift, and SwiftUI recreates cells. Always use a stable unique identifier (like a database ID or UUID). Another identity trap: using .id(someValue) that changes on every render—this forces a full view teardown and rebuild, which can cause state loss and performance issues.
SwiftUI views can conform to Equatable to prevent unnecessary re-renders. But if the Equatable implementation returns true (equal) even when the data actually changed, the view won't update. I've seen teams add .equatable() to all views for 'performance' and then wonder why the UI is stale.
The fix: either remove .equatable() entirely (SwiftUI's default diffing is usually fast enough) or ensure the Equatable conformance compares all the properties that drive the view's layout. For simple views, it's safer to let SwiftUI handle diffing.
SwiftUI's state observation is tightly coupled to the main actor. When you update a @Published property from a background thread, SwiftUI doesn't detect the change. This is the single most common cause of 'state not updating' in real-world apps.
If you use async/await, wrap the mutation in await MainActor.run { ... }. For Combine, add .receive(on: DispatchQueue.main) before the sink or assign. For callback-based code (e.g., URLSession delegates), use DispatchQueue.main.async. I also recommend adding a runtime assertion: assert(Thread.isMainThread, "State mutation on background thread") in your model's property setters.
Xcode's SwiftUI Instruments (Profile > SwiftUI) is underused. It shows every view body evaluation, the time taken, and the dependencies that triggered it. If your view isn't re-evaluating, you'll see no entry for it. That tells you the dependency tracking failed.
The View Debugger (Debug > View Debugging > Capture View Hierarchy) shows the current view tree. If you see stale versions of your view (e.g., old text values), it's likely an identity issue. You can also use the Environment inspector to check if the state is being passed correctly through the hierarchy.
Frequently asked questions
Why does my @State variable update but the UI doesn't change?
This usually means the view's body isn't re-evaluated. Check if the view is using a computed property that creates a new view instance each time (identity change), or if you're updating the state from a background thread. Also verify that you're not using .equatable() that prevents the update.
Should I use @StateObject or @ObservedObject?
Use @StateObject when the view creates the observable object (owning it). Use @ObservedObject when the object is passed from a parent. Using @ObservedObject on an object created within the view will cause the object to be recreated on every render, losing state.
How do I update a @Published property from a background thread?
Wrap the assignment in DispatchQueue.main.async { ... } or use await MainActor.run { ... } in async code. For Combine, add .receive(on: DispatchQueue.main) before the sink. Never mutate @Published directly from a background queue.
Why does ForEach with array indices cause update issues?
When you use indices as ids, adding or removing items shifts all subsequent indices, causing SwiftUI to treat each cell as a new identity. This can cause unexpected re-renders, loss of state (like scroll position), or failure to update if the old cell is reused. Always use a stable id like a UUID or database primary key.
Can .onAppear cause state update problems?
Yes. If .onAppear is inside a conditional view, it won't fire again when the condition changes and the view reappears. Also, if you set state inside .onAppear, it may cause a re-render that cancels the current transaction. Prefer .task or .onChange for side effects tied to state.