Use when debugging SwiftUI view updates, preview crashes, or layout issues - diagnostic decision trees to identify root causes quickly and avoid misdiagnosis under pressure
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
SwiftUI debugging falls into three categories, each with a different diagnostic approach:
Core principle: Start with observable symptoms, test systematically, eliminate causes one by one. Don't guess.
Requires: Xcode 26+, iOS 17+ (iOS 14-16 patterns still valid, see notes)
Related skills: xcode-debugging (cache corruption diagnosis), swift-concurrency (observer patterns), swiftui-performance (profiling with Instruments), swiftui-layout (adaptive layout patterns)
These are real questions developers ask that this skill is designed to answer:
→ The skill walks through the decision tree to identify struct mutation vs lost binding vs missing observer
→ The skill shows how to provide missing dependencies with .environment() or .environmentObject()
→ The skill identifies accidental view recreation from conditionals and shows .opacity() fix
→ The skill explains when to use @State vs plain properties with @Observable objects
→ The skill identifies ForEach identity issues and shows how to use stable IDs
xcode-debugging instead whenswift-concurrency instead whenSwiftUI provides a debug-only method to understand why a view's body was called.
Usage in LLDB:
// Set breakpoint in view's body
// In LLDB console:
(lldb) expression Self._printChanges()
Temporary in code (remove before shipping):
var body: some View {
let _ = Self._printChanges() // Debug only
Text("Hello")
}
Output interpretation:
MyView: @self changed
- Means the view value itself changed (parameters passed to view)
MyView: count changed
- Means @State property "count" triggered the update
MyView: (no output)
- Body not being called; view not updating at all
⚠️ Important:
When to use:
Cross-reference: For complex update patterns, use SwiftUI Instrument → see swiftui-performance skill
The most common frustration: you changed @State but the view didn't redraw. The root cause is always one of four things.
#Preview {
YourView()
}
YES → The problem is in your code. Continue to Step 2.
NO → It's likely Xcode state or cache corruption. Skip to Preview Crashes section.
Symptom: You modify a @State value directly, but the view doesn't update.
Why it happens: SwiftUI doesn't see direct mutations on structs. You need to reassign the entire value.
// ❌ WRONG: Direct mutation doesn't trigger update
@State var items: [String] = []
func addItem(_ item: String) {
items.append(item) // SwiftUI doesn't see this change
}
// ✅ RIGHT: Reassignment triggers update
@State var items: [String] = []
func addItem(_ item: String) {
var newItems = items
newItems.append(item)
self.items = newItems // Full reassignment
}
// ✅ ALSO RIGHT: Use a binding
@State var items: [String] = []
var itemsBinding: Binding<[String]> {
Binding(
get: { items },
set: { items = $0 }
)
}
Fix it: Always reassign the entire struct value, not pieces of it.
Symptom: You pass a binding to a child view, but changes in the child don't update the parent.
Why it happens: You're passing .constant() or creating a new binding each time, breaking the two-way connection.
// ❌ WRONG: Constant binding is read-only
@State var isOn = false
ToggleChild(value: .constant(isOn)) // Changes ignored
// ❌ WRONG: New binding created each render
@State var name = ""
TextField("Name", text: Binding(
get: { name },
set: { name = $0 }
)) // New binding object each time parent renders
// ✅ RIGHT: Pass the actual binding
@State var isOn = false
ToggleChild(value: $isOn)
// ✅ RIGHT (iOS 17+): Use @Bindable for @Observable objects
@Observable class Book {
var title = "Sample"
var isAvailable = true
}
struct EditView: View {
@Bindable var book: Book // Enables $book.title syntax
var body: some View {
TextField("Title", text: $book.title)
Toggle("Available", isOn: $book.isAvailable)
}
}
// ✅ ALSO RIGHT (iOS 17+): @Bindable as local variable
struct ListView: View {
@State private var books = [Book(), Book()]
var body: some View {
List(books) { book in
@Bindable var book = book // Inline binding
TextField("Title", text: $book.title)
}
}
}
// ✅ RIGHT (pre-iOS 17): Create binding once, not in body
@State var name = ""
@State var nameBinding: Binding<String>?
var body: some View {
if nameBinding == nil {
nameBinding = Binding(
get: { name },
set: { name = $0 }
)
}
return TextField("Name", text: nameBinding!)
}
Fix it: Pass $state directly when possible. For @Observable objects (iOS 17+), use @Bindable. If creating custom bindings (pre-iOS 17), create them in init or cache them, not in body.
Symptom: The view updates, but @State values reset to initial state. You see brief flashes of initial values.
Why it happens: The view got a new identity (removed from a conditional, moved in a container, or the container itself was recreated), causing SwiftUI to treat it as a new view.
// ❌ WRONG: View identity changes when condition flips
@State var count = 0
var body: some View {
VStack {
if showCounter {
Counter() // Gets new identity each time showCounter changes
}
Button("Toggle") {
showCounter.toggle()
}
}
}
// Counter gets recreated, @State count resets to 0
// ✅ RIGHT: Preserve identity with opacity or hidden
@State var count = 0
var body: some View {
VStack {
Counter()
.opacity(showCounter ? 1 : 0)
Button("Toggle") {
showCounter.toggle()
}
}
}
// ✅ ALSO RIGHT: Use id() if you must conditionally show
@State var count = 0
var body: some View {
VStack {
if showCounter {
Counter()
.id("counter") // Stable identity
}
Button("Toggle") {
showCounter.toggle()
}
}
}
Fix it: Preserve view identity by using .opacity() instead of conditionals, or apply .id() with a stable identifier.
Symptom: An object changed, but views observing it didn't update.
Why it happens: SwiftUI doesn't know to watch for changes in the object.
// ❌ WRONG: Property changes don't trigger update
class Model {
var count = 0 // Not observable
}
struct ContentView: View {
let model = Model() // New instance each render, not observable
var body: some View {
Text("\(model.count)")
Button("Increment") {
model.count += 1 // View doesn't update
}
}
}
// ✅ RIGHT (iOS 17+): Use @Observable with @State
@Observable class Model {
var count = 0 // No @Published needed
}
struct ContentView: View {
@State private var model = Model() // @State, not @StateObject
var body: some View {
Text("\(model.count)")
Button("Increment") {
model.count += 1 // View updates
}
}
}
// ✅ RIGHT (iOS 17+): Injected @Observable objects
struct ContentView: View {
var model: Model // Just a plain property
var body: some View {
Text("\(model.count)") // View updates when count changes
}
}
// ✅ RIGHT (iOS 17+): @Observable with environment
@Observable class AppModel {
var count = 0
}
@main
struct MyApp: App {
@State private var model = AppModel()
var body: some Scene {
WindowGroup {
ContentView()
.environment(model) // Add to environment
}
}
}
struct ContentView: View {
@Environment(AppModel.self) private var model // Read from environment
var body: some View {
Text("\(model.count)")
}
}
// ✅ RIGHT (pre-iOS 17): Use @StateObject/ObservableObject
class Model: ObservableObject {
@Published var count = 0
}
struct ContentView: View {
@StateObject var model = Model() // For owned instances
var body: some View {
Text("\(model.count)")
Button("Increment") {
model.count += 1 // View updates
}
}
}
// ✅ RIGHT (pre-iOS 17): Use @ObservedObject for injected instances
struct ContentView: View {
@ObservedObject var model: Model // Passed in from parent
var body: some View {
Text("\(model.count)")
}
}
Fix it (iOS 17+): Use @Observable macro on your class, then @State to store it. Views automatically track dependencies on properties they read.
Fix it (pre-iOS 17): Use @StateObject if you own the object, @ObservedObject if it's injected, or @EnvironmentObject if it's shared across the tree.
Why @Observable is better (iOS 17+):
@Published wrapper needed@State instead of @StateObject@ObservedObjectSee also: Managing model data in your app
View not updating?
├─ Can reproduce in preview?
│ ├─ YES: Problem is in code
│ │ ├─ Modified struct directly? → Struct Mutation
│ │ ├─ Passed binding to child? → Lost Binding Identity
│ │ ├─ View inside conditional? → Accidental Recreation
│ │ └─ Object changed but view didn't? → Missing Observer
│ └─ NO: Likely cache/Xcode state → See Preview Crashes
When your preview won't load or crashes immediately, the three root causes are distinct.
Root cause: Preview missing a required dependency (@EnvironmentObject, @Environment, imported module).
// ❌ WRONG: ContentView needs a model, preview doesn't provide it
struct ContentView: View {
@EnvironmentObject var model: AppModel
var body: some View {
Text(model.title)
}
}
#Preview {
ContentView() // Crashes: model not found
}
// ✅ RIGHT: Provide the dependency
#Preview {
ContentView()
.environmentObject(AppModel())
}
// ✅ ALSO RIGHT: Check for missing imports
// If using custom types, make sure they're imported in preview file
#Preview {
MyCustomView() // Make sure MyCustomView is defined or imported
}
Fix it: Trace the error, find what's missing, provide it to the preview.
Root cause: State initialization failed at runtime. The view tried to access data that doesn't exist.
// ❌ WRONG: Index out of bounds at runtime
struct ListView: View {
@State var selectedIndex = 10
let items = ["a", "b", "c"]
var body: some View {
Text(items[selectedIndex]) // Crashes: index 10 doesn't exist
}
}
// ❌ WRONG: Optional forced unwrap fails
struct DetailView: View {
@State var data: Data?
var body: some View {
Text(data!.title) // Crashes if data is nil
}
}
// ✅ RIGHT: Safe defaults
struct ListView: View {
@State var selectedIndex = 0 // Valid index
let items = ["a", "b", "c"]
var body: some View {
if selectedIndex < items.count {
Text(items[selectedIndex])
}
}
}
// ✅ RIGHT: Handle optionals
struct DetailView: View {
@State var data: Data?
var body: some View {
if let data = data {
Text(data.title)
} else {
Text("No data")
}
}
}
Fix it: Review your @State initializers. Check array bounds, optional unwraps, and default values.
Root cause: Xcode cache corruption. The preview process has stale information about your code.
Diagnostic checklist:
Fix it (in order):
Cmd+Option+Prm -rf ~/Library/Developer/Xcode/DerivedDataCmd+BIf still broken after all four steps: It's not cache, see Error Types 1 or 2.
Preview crashes?
├─ Error message visible?
│ ├─ "Cannot find in scope" → Missing Dependency
│ ├─ "Fatal error" or silent crash → State Init Failure
│ └─ No error → Likely Cache Corruption
└─ Try: Restart Preview → Restart Xcode → Nuke DerivedData
Layout problems are usually visually obvious. Match your symptom to the pattern.
Symptom: Views stacked on top of each other, some invisible.
Root cause: Z-order is wrong or you're not controlling visibility.
// ❌ WRONG: Can't see the blue view
ZStack {
Rectangle().fill(.blue)
Rectangle().fill(.red)
}
// ✅ RIGHT: Use zIndex to control layer order
ZStack {
Rectangle().fill(.blue).zIndex(0)
Rectangle().fill(.red).zIndex(1)
}
// ✅ ALSO RIGHT: Hide instead of removing from hierarchy
ZStack {
Rectangle().fill(.blue)
Rectangle().fill(.red).opacity(0.5)
}
Symptom: View is tiny or taking up the entire screen unexpectedly.
Root cause: GeometryReader sizes itself to available space; parent doesn't constrain it.
// ❌ WRONG: GeometryReader expands to fill all available space
VStack {
GeometryReader { geo in
Text("Size: \(geo.size)")
}
Button("Next") { }
}
// Text takes entire remaining space
// ✅ RIGHT: Constrain the geometry reader
VStack {
GeometryReader { geo in
Text("Size: \(geo.size)")
}
.frame(height: 100)
Button("Next") { }
}
Symptom: Content hidden behind notch, or not using full screen space.
Root cause: .ignoresSafeArea() applied to wrong view.
// ❌ WRONG: Only the background ignores safe area
ZStack {
Color.blue.ignoresSafeArea()
VStack {
Text("Still respects safe area")
}
}
// ✅ RIGHT: Container ignores, children position themselves
ZStack {
Color.blue
VStack {
Text("Can now use full space")
}
}
.ignoresSafeArea()
// ✅ ALSO RIGHT: Be selective about which edges
ZStack {
Color.blue
VStack { ... }
}
.ignoresSafeArea(edges: .horizontal) // Only horizontal
Symptom: Text truncated, buttons larger than text, sizing behavior unpredictable.
Root cause: Mixing frame() (constrains) with fixedSize() (expands to content).
// ❌ WRONG: fixedSize() overrides frame()
Text("Long text here")
.frame(width: 100)
.fixedSize() // Overrides the frame constraint
// ✅ RIGHT: Use frame() to constrain
Text("Long text here")
.frame(width: 100, alignment: .leading)
.lineLimit(1)
// ✅ RIGHT: Use fixedSize() only for natural sizing
VStack(spacing: 0) {
Text("Small")
.fixedSize() // Sizes to text
Text("Large")
.fixedSize()
}
Symptom: Padding, corners, or shadows appearing in wrong place.
Root cause: Applying modifiers in wrong order. SwiftUI applies bottom-to-top.
// ❌ WRONG: Corners applied after padding
Text("Hello")
.padding()
.cornerRadius(8) // Corners are too large
// ✅ RIGHT: Corners first, then padding
Text("Hello")
.cornerRadius(8)
.padding()
// ❌ WRONG: Shadow after frame
Text("Hello")
.frame(width: 100)
.shadow(radius: 4) // Shadow only on frame bounds
// ✅ RIGHT: Shadow includes all content
Text("Hello")
.shadow(radius: 4)
.frame(width: 100)
SwiftUI uses view identity to track views over time, preserve state, and animate transitions. Understanding identity is critical for debugging state preservation and animation issues.
Position in view hierarchy determines identity:
VStack {
Text("First") // Identity: VStack.child[0]
Text("Second") // Identity: VStack.child[1]
}
When structural identity changes:
if showDetails {
DetailView() // Identity changes when condition changes
SummaryView()
} else {
SummaryView() // Same type, different position = different identity
}
Problem: SummaryView gets recreated each time, losing @State values.
You control identity with .id() modifier:
DetailView()
.id(item.id) // Explicit identity tied to item
// When item.id changes → SwiftUI treats as different view
// → @State resets
// → Animates transition
Symptom: @State values reset to initial values when you don't expect.
Cause: View identity changed (position in hierarchy or .id() value changed).
// ❌ PROBLEM: Identity changes when showDetails toggles
@State private var count = 0
var body: some View {
VStack {
if showDetails {
CounterView(count: $count) // Position changes
}
Button("Toggle") {
showDetails.toggle()
}
}
}
// ✅ FIX: Stable identity with .opacity()
var body: some View {
VStack {
CounterView(count: $count)
.opacity(showDetails ? 1 : 0) // Same identity always
Button("Toggle") {
showDetails.toggle()
}
}
}
// ✅ ALSO FIX: Explicit stable ID
var body: some View {
VStack {
if showDetails {
CounterView(count: $count)
.id("counter") // Stable ID
}
Button("Toggle") {
showDetails.toggle()
}
}
}
Symptom: View changes but doesn't animate.
Cause: Identity changed, SwiftUI treats as remove + add instead of update.
// ❌ PROBLEM: Identity changes with selection
ForEach(items) { item in
ItemView(item: item)
.id(item.id + "-\(selectedID)") // ID changes when selection changes
}
// ✅ FIX: Stable identity
ForEach(items) { item in
ItemView(item: item, isSelected: item.id == selectedID)
.id(item.id) // Stable ID
}
Symptom: List items jump around or animate incorrectly.
Cause: Non-unique or changing identifiers.
// ❌ WRONG: Index-based ID changes when array changes
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
Text(item.name)
}
// ❌ WRONG: Non-unique IDs
ForEach(items, id: \.category) { item in // Multiple items per category
Text(item.name)
}
// ✅ RIGHT: Stable, unique IDs
ForEach(items, id: \.id) { item in
Text(item.name)
}
// ✅ RIGHT: Make type Identifiable
struct Item: Identifiable {
let id = UUID()
var name: String
}
ForEach(items) { item in // id: \.id implicit
Text(item.name)
}
Use .id() to:
Example: Force recreation on data change:
DetailView(item: item)
.id(item.id) // New item → new view → @State resets
Don't use .id() when:
var body: some View {
let _ = Self._printChanges()
// Check if "@self changed" appears when you don't expect
}
Search codebase for .id() - are IDs changing unexpectedly?
Views in if/else change position → different identity.
Fix: Use .opacity() or stable .id() instead.
| Symptom | Likely Cause | Fix |
|---|---|---|
| State resets | Identity change | Use .opacity() instead of if |
| No animation | Identity change | Remove .id() or use stable ID |
| ForEach jumps | Non-unique ID | Use unique, stable IDs |
| Unexpected recreation | Conditional position | Add explicit .id() |
See also: WWDC21: Demystify SwiftUI
When you're under deadline pressure, you'll be tempted to shortcuts that hide problems instead of fixing them.
The danger: You skip diagnosis, cache issue recurs after 2 weeks in production, you're debugging while users hit crashes.
What to do instead (5-minute protocol, total):
Cmd+Option+P (30 seconds)rm -rf ~/Library/Developer/Xcode/DerivedData (30 seconds)Cmd+B (2 minutes)Time cost: 5 minutes diagnosis + 2 minutes fix = 7 minutes total
Cost of skipping: 30 min shipping + 24 hours debug cycle = 24+ hours total
The danger: You're treating symptoms, not diagnosing. Same view won't update in other contexts. You've just hidden the bug.
What to do instead (2-minute diagnosis):
Decision principle: If you can't name the specific root cause, you haven't diagnosed yet. Don't code until you can answer "the problem is struct mutation because...".
The danger: You're exhausted after 2 hours of guessing. You're 17 hours from App Store submission. You're panicking. Every minute feels urgent, so you stop diagnosing and start flailing.
Intermittent bugs are the MOST important to diagnose correctly. One wrong guess now creates a new bug. You ship with a broken view AND a new bug. App Store rejects you. You miss launch.
What to do instead (60-minute systematic diagnosis):
Step 1: Reproduce in preview (15 min)
Step 2: Isolate the variable (15 min)
if logic that might recreate the parent? Remove it and testStep 3: Apply the specific fix (30 min)
.opacity() instead of conditionalsStep 4: Verify 100% reliability (until submission)
Time cost: 60 minutes diagnosis + 30 minutes fix + confidence = submit at 9am
Cost of guessing: 2 hours already + 3 more hours guessing + new bug introduced + crash reports post-launch + emergency patch + reputation damage = miss launch + post-launch chaos
The decision principle: Intermittent bugs require SYSTEMATIC diagnosis. The slower you go in diagnosis, the faster you get to the fix. Guessing is the fastest way to disaster.
"I appreciate the suggestion. Adding @ObservedObject everywhere is treating the symptom, not the root cause. The skill says intermittent bugs create NEW bugs when we guess. I need 60 minutes for systematic diagnosis. If I can't find the root cause by then, we'll disable the feature and ship a clean v1.1. The math shows we have time—I can complete diagnosis, fix, AND verification before the deadline."
The danger: Magic numbers break on other sizes. SafeArea ignoring is often wrong. Locking to iPhone means you ship a broken iPad experience.
What to do instead (3-minute diagnosis):
Time cost: 3 minutes diagnosis + 5 minutes fix = 8 minutes total
Cost of magic numbers: Ship wrong, report 2 weeks later, debug 4 hours, patch in update = 2+ weeks delay
// Fix 1: Reassign the full struct
@State var items: [String] = []
var newItems = items
newItems.append("new")
self.items = newItems
// Fix 2: Pass binding correctly
@State var value = ""
ChildView(text: $value) // Pass binding, not value
// Fix 3: Preserve view identity
View().opacity(isVisible ? 1 : 0) // Not: if isVisible { View() }
// Fix 4: Observe the object
@StateObject var model = MyModel()
@ObservedObject var model: MyModel
// Fix 1: Provide dependencies
#Preview {
ContentView()
.environmentObject(AppModel())
}
// Fix 2: Safe defaults
@State var index = 0 // Not 10, if array has 3 items
// Fix 3: Nuke cache
// Terminal: rm -rf ~/Library/Developer/Xcode/DerivedData
// Fix 1: Z-order
Rectangle().zIndex(1)
// Fix 2: Constrain GeometryReader
GeometryReader { geo in ... }.frame(height: 100)
// Fix 3: SafeArea
ZStack { ... }.ignoresSafeArea()
// Fix 4: Modifier order
Text().cornerRadius(8).padding() // Corners first
Scenario: You have a list of tasks. When you tap a task to mark it complete, the checkmark should appear, but it doesn't.
Code:
struct TaskListView: View {
@State var tasks: [Task] = [...]
var body: some View {
List {
ForEach(tasks, id: \.id) { task in
HStack {
Image(systemName: task.isComplete ? "checkmark.circle.fill" : "circle")
Text(task.title)
Spacer()
Button("Done") {
// ❌ WRONG: Direct mutation
task.isComplete.toggle()
}
}
}
}
}
}
Diagnosis using the skill:
Fix:
Button("Done") {
// ✅ RIGHT: Full reassignment
if let index = tasks.firstIndex(where: { $0.id == task.id }) {
tasks[index].isComplete.toggle()
}
}
Why this works: SwiftUI detects the array reassignment, triggering a redraw. The task in the List updates.
Scenario: You created a custom data model. It works fine in the app, but the preview crashes with "Cannot find 'CustomModel' in scope".
Code:
import SwiftUI
// ❌ WRONG: Preview missing the dependency
#Preview {
TaskDetailView(task: Task(...))
}
struct TaskDetailView: View {
@Environment(\.modelContext) var modelContext
let task: Task // Custom model
var body: some View {
Text(task.title)
}
}
Diagnosis using the skill:
Fix:
#Preview {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: Task.self, configurations: config)
return TaskDetailView(task: Task(title: "Sample"))
.modelContainer(container)
}
Why this works: Providing the environment object and model container satisfies the view's dependencies. Preview loads successfully.
Scenario: You have a search field. You type characters, but the text doesn't appear in the UI. However, the search results DO update.
Code:
struct SearchView: View {
@State var searchText = ""
var body: some View {
VStack {
// ❌ WRONG: Passing constant binding
TextField("Search", text: .constant(searchText))
Text("Results for: \(searchText)") // This updates
List {
ForEach(results(for: searchText), id: \.self) { result in
Text(result)
}
}
}
}
func results(for text: String) -> [String] {
// Returns filtered results
}
}
Diagnosis using the skill:
Fix:
// ✅ RIGHT: Pass the actual binding
TextField("Search", text: $searchText)
Why this works: $searchText passes a two-way binding. TextField writes changes back to @State, triggering a redraw. Text field now shows typed characters.
After fixing SwiftUI issues, verify with visual confirmation in the simulator.
SwiftUI previews don't always match simulator behavior:
Use simulator verification for:
# 1. Take "before" screenshot
/axiom:screenshot
# 2. Apply your fix
# 3. Rebuild and relaunch
xcodebuild build -scheme YourScheme
# 4. Take "after" screenshot
/axiom:screenshot
# 5. Compare screenshots to verify fix
If the bug is deep in your app, use debug deep links to navigate directly:
# 1. Add debug deep links (see deep-link-debugging skill)
# Example: debug://settings, debug://recipe-detail?id=123
# 2. Navigate and capture
xcrun simctl openurl booted "debug://problem-screen"
sleep 1
/axiom:screenshot
For complex scenarios (state setup, multiple steps, log analysis):
/axiom:test-simulator
Then describe what you want to test:
Before fix (view not updating):
# 1. Reproduce bug
xcrun simctl openurl booted "debug://recipe-list"
sleep 1
xcrun simctl io booted screenshot /tmp/before-fix.png
# Screenshot shows: Tapping star doesn't update UI
After fix (added @State binding):
# 2. Test fix
xcrun simctl openurl booted "debug://recipe-list"
sleep 1
xcrun simctl io booted screenshot /tmp/after-fix.png
# Screenshot shows: Star updates immediately when tapped
Time saved: 60%+ faster iteration with visual verification vs manual navigation
swiftui-performance — For profiling with Instruments, Cause & Effect Graphswiftui-debugging-diag — Systematic diagnostic workflows for complex casesxcode-debugging — For Xcode cache corruption, build issuesswift-concurrency — For @MainActor and async/await patternsTargets: iOS 17+ (iOS 14-16 patterns still valid) Xcode: 26+ Framework: SwiftUI History: See git log for changes