SwiftUI Debugging Diagnostics
When to Use This Diagnostic Skill
Use this skill when:
- •Basic troubleshooting failed — Applied
axiom-swiftui-debuggingskill patterns but issue persists - •Self._printChanges() shows unexpected patterns — View updating when it shouldn't, or not updating when it should
- •Intermittent issues — Works sometimes, fails other times ("heisenbug")
- •Complex dependency chains — Need to trace data flow through multiple views/models
- •Performance investigation — Views updating too often or taking too long
- •Preview mysteries — Crashes or failures that aren't immediately obvious
FORBIDDEN Actions
Under pressure, you'll be tempted to shortcuts that hide problems instead of diagnosing them. NEVER do these:
❌ Guessing with random @State/@Observable changes
- •"Let me try adding @Observable here and see if it works"
- •"Maybe if I change this to @StateObject it'll fix it"
❌ Adding .id(UUID()) to force updates
- •Creates new view identity every render
- •Destroys state preservation
- •Masks root cause
❌ Using ObservableObject when @Observable would work (iOS 17+)
- •Adds unnecessary complexity
- •Miss out on automatic dependency tracking
❌ Ignoring intermittent issues ("works sometimes")
- •"I'll just merge and hope it doesn't happen in production"
- •Intermittent = systematic bug, not randomness
❌ Shipping without understanding
- •"The fix works, I don't know why"
- •Production is too expensive for trial-and-error
Mandatory First Steps
Before diving into diagnostic patterns, establish baseline environment:
# 1. Verify Instruments setup xcodebuild -version # Must be Xcode 26+ for SwiftUI Instrument # 2. Build in Release mode for profiling xcodebuild build -scheme YourScheme -configuration Release # 3. Clear derived data if investigating preview issues rm -rf ~/Library/Developer/Xcode/DerivedData
Time cost: 5 minutes Why: Wrong Xcode version or Debug mode produces misleading profiling data
Diagnostic Decision Tree
SwiftUI view issue after basic troubleshooting?
│
├─ View not updating?
│ ├─ Basic check: Add Self._printChanges() temporarily
│ │ ├─ Shows "@self changed" → View value changed
│ │ │ └─ Pattern D1: Analyze what caused view recreation
│ │ ├─ Shows specific state property → That state triggered update
│ │ │ └─ Verify: Should that state trigger update?
│ │ └─ Nothing logged → Body not being called at all
│ │ └─ Pattern D3: View Identity Investigation
│ └─ Advanced: Use SwiftUI Instrument
│ └─ Pattern D2: SwiftUI Instrument Investigation
│
├─ View updating too often?
│ ├─ Pattern D1: Self._printChanges() Analysis
│ │ └─ Identify unnecessary state dependencies
│ └─ Pattern D2: SwiftUI Instrument → Cause & Effect Graph
│ └─ Trace data flow, find broad dependencies
│
├─ Intermittent issues (works sometimes)?
│ ├─ Pattern D3: View Identity Investigation
│ │ └─ Check: Does identity change unexpectedly?
│ ├─ Pattern D4: Environment Dependency Check
│ │ └─ Check: Environment values changing frequently?
│ └─ Reproduce in preview 30+ times
│ └─ If can't reproduce: Likely timing/race condition
│
└─ Preview crashes (after basic fixes)?
├─ Pattern D5: Preview Diagnostics (Xcode 26)
│ └─ Check diagnostics button, crash logs
└─ If still fails: Pattern D2 (profile preview build)
Diagnostic Patterns
Pattern D1: Self._printChanges() Analysis
Time cost: 5 minutes
Symptom: Need to understand exactly why view body runs
When to use:
- •View updating more often than expected
- •View not updating when it should
- •Verifying dependencies after refactoring
Technique:
struct MyView: View {
@State private var count = 0
@Environment(AppModel.self) private var model
var body: some View {
let _ = Self._printChanges() // Add temporarily
VStack {
Text("Count: \(count)")
Text("Model value: \(model.value)")
}
}
}
Output interpretation:
# Scenario 1: View parameter changed MyView: @self changed → Parent passed new MyView instance → Check parent code - what triggered recreation? # Scenario 2: State property changed MyView: count changed → Local @State triggered update → Expected if you modified count # Scenario 3: Environment property changed MyView: @self changed # Environment is part of @self → Environment value changed (color scheme, locale, custom value) → Pattern D4: Check environment dependencies # Scenario 4: Nothing logged → Body not being called → Pattern D3: View identity investigation
Common discoveries:
- •
"@self changed" when you don't expect
- •Parent recreating view unnecessarily
- •Check parent's state management
- •
Property shows changed but you didn't change it
- •Indirect dependency (reading from object that changed)
- •Pattern D2: Use Instruments to trace
- •
Multiple properties changing together
- •Broad dependency (e.g., reading entire array when only need one item)
- •Fix: Extract specific dependency
Verification:
- •Remove
Self._printChanges()call before committing - •Never ship to production with this code
Cross-reference: For complex cases, use Pattern D2 (SwiftUI Instrument)
Pattern D2: SwiftUI Instrument Investigation
Time cost: 25 minutes
Symptom: Complex update patterns that Self._printChanges() can't fully explain
When to use:
- •Multiple views updating when one should
- •Need to trace data flow through app
- •Views updating but don't know which data triggered it
- •Long view body updates (performance issue)
Prerequisites:
- •Xcode 26+ installed
- •Device updated to iOS 26+ / macOS Tahoe+
- •Build in Release mode
Steps:
1. Launch Instruments (5 min)
# Build Release xcodebuild build -scheme YourScheme -configuration Release # Launch Instruments # Press Command-I in Xcode # Choose "SwiftUI" template
2. Record Trace (3 min)
- •Click Record button
- •Perform the action that triggers unexpected updates
- •Stop recording (10-30 seconds of interaction is enough)
3. Analyze Long View Body Updates (5 min)
- •Look at Long View Body Updates lane
- •Any orange/red bars? Those are expensive views
- •Click on a long update → Detail pane shows view name
- •Right-click → "Set Inspection Range and Zoom"
- •Switch to Time Profiler track
- •Find your view in call stack
- •Identify expensive operation (formatter creation, calculation, etc.)
Fix: Move expensive operation to model layer, cache result
4. Analyze Unnecessary Updates (7 min)
- •Highlight time range of user action (e.g., tapping favorite button)
- •Expand hierarchy in detail pane
- •Count updates — more than expected?
- •Hover over view → Click arrow → "Show Cause & Effect Graph"
5. Interpret Cause & Effect Graph (5 min)
Graph nodes:
[Blue node] = Your code (gesture, state change, view body) [System node] = SwiftUI/system work [Arrow labeled "update"] = Caused this update [Arrow labeled "creation"] = Caused view to appear
Common patterns:
# Pattern A: Single view updates (GOOD) [Gesture] → [State Change in ViewModelA] → [ViewA body] # Pattern B: All views update (BAD - broad dependency) [Gesture] → [Array change] → [All list item views update] └─ Fix: Use granular view models, one per item # Pattern C: Cascade through environment (CHECK) [State Change] → [Environment write] → [Many view bodies check] └─ If environment value changes frequently → Pattern D4 fix
Click on nodes:
- •State change node → See backtrace of where value was set
- •View body node → See which properties it read (dependencies)
Verification:
- •Record new trace after fix
- •Compare before/after update counts
- •Verify red/orange bars reduced or eliminated
Cross-reference: axiom-swiftui-performance skill for detailed Instruments workflows
Pattern D3: View Identity Investigation
Time cost: 15 minutes
Symptom: @State values reset unexpectedly, or views don't animate
When to use:
- •Counter resets to 0 when it shouldn't
- •Animations don't work (view pops instead of animates)
- •ForEach items jump around
- •Text field loses focus
Root cause: View identity changed unexpectedly
Investigation steps:
1. Check for conditional placement (5 min)
// ❌ PROBLEM: Identity changes with condition
if showDetails {
CounterView() // Gets new identity each time showDetails toggles
}
// ✅ FIX: Use .opacity()
CounterView()
.opacity(showDetails ? 1 : 0) // Same identity always
Find: Search codebase for views inside if/else that hold state
2. Check .id() modifiers (5 min)
// ❌ PROBLEM: .id() changes when data changes
DetailView()
.id(item.id + "-\(isEditing)") // ID changes with isEditing
// ✅ FIX: Stable ID
DetailView()
.id(item.id) // Stable ID
Find: Search codebase for .id( — check if ID values change
3. Check ForEach identifiers (5 min)
// ❌ WRONG: Index-based ID
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
Text(item.name)
}
// ❌ WRONG: Non-unique ID
ForEach(items, id: \.category) { item in // Multiple items per category
Text(item.name)
}
// ✅ RIGHT: Unique, stable ID
ForEach(items, id: \.id) { item in
Text(item.name)
}
Find: Search for ForEach — verify unique, stable IDs
Fix patterns:
| Issue | Fix |
|---|---|
| View in conditional | Use .opacity() instead |
| .id() changes too often | Use stable identifier |
| ForEach jumping | Use unique, stable IDs (UUID or server ID) |
| State resets on navigation | Check NavigationStack path management |
Verification:
- •Add Self._printChanges() — should NOT see "@self changed" repeatedly
- •Animations should now work smoothly
- •@State values should persist
Pattern D4: Environment Dependency Check
Time cost: 10 minutes
Symptom: Many views updating when unrelated data changes
When to use:
- •Cause & Effect Graph shows "Environment" node triggering many updates
- •Slow scrolling or animation performance
- •Unexpected cascading updates
Root cause: Frequently-changing value in environment OR too many views reading environment
Investigation steps:
1. Find environment writes (3 min)
# Search for environment modifiers in current project
grep -r "\.environment(" --include="*.swift" .
Look for:
// ❌ BAD: Frequently changing values .environment(\.scrollOffset, scrollOffset) // Updates 60+ times/second .environment(model) // If model updates frequently // ✅ GOOD: Stable values .environment(\.colorScheme, .dark) .environment(appModel) // If appModel changes rarely
2. Check what's in environment (3 min)
Using Pattern D2 (Instruments), check Cause & Effect Graph:
- •Click on "Environment" node
- •See which properties changed
- •Count how many views checked for updates
Questions:
- •Is this value changing every scroll/animation frame?
- •Do all these views actually need this value?
3. Apply fix (4 min)
Fix A: Remove from environment (if frequently changing):
// ❌ Before: Environment .environment(\.scrollOffset, scrollOffset) // ✅ After: Direct parameter ChildView(scrollOffset: scrollOffset)
Fix B: Use @Observable model (if needed by many views):
// Instead of storing primitive in environment:
@Observable class ScrollViewModel {
var offset: CGFloat = 0
}
// Views depend on specific properties:
@Environment(ScrollViewModel.self) private var viewModel
var body: some View {
Text("\(viewModel.offset)") // Only updates when offset changes
}
Verification:
- •Record new trace in Instruments
- •Check Cause & Effect Graph — fewer views should update
- •Performance should improve (smoother scrolling/animations)
Pattern D5: Preview Diagnostics (Xcode 26)
Time cost: 10 minutes
Symptom: Preview won't load or crashes with unclear error
When to use:
- •Preview fails after basic fixes (swiftui-debugging skill)
- •Error message unclear or generic
- •Preview worked before, stopped suddenly
Investigation steps:
1. Use Preview Diagnostics Button (2 min)
Location: Editor menu → Canvas → Diagnostics
What it shows:
- •Detailed error messages
- •Missing dependencies
- •State initialization issues
- •Preview-specific problems
2. Check crash logs (3 min)
# Open crash logs directory open ~/Library/Logs/DiagnosticReports/ # Look for recent .crash files containing "Preview" ls -lt ~/Library/Logs/DiagnosticReports/ | grep -i preview | head -5
What to look for:
- •Fatal errors (array out of bounds, force unwrap nil)
- •Missing module imports
- •Framework initialization failures
3. Isolate the problem (5 min)
Create minimal preview:
// Start with empty preview
#Preview {
Text("Test")
}
// If this works, gradually add:
#Preview {
MyView() // Your actual view, but with mock data
.environment(MockModel()) // Provide all dependencies
}
// Find which dependency causes crash
Common issues:
| Error | Cause | Fix |
|---|---|---|
| "Cannot find in scope" | Missing dependency | Add to preview (see example below) |
| "Fatal error: Unexpectedly found nil" | Optional unwrap failed | Provide non-nil value in preview |
| "No such module" | Import missing | Add import statement |
| Silent crash (no error) | State init with invalid value | Use safe defaults |
Fix patterns:
// Missing @Environment
#Preview {
ContentView()
.environment(AppModel()) // Provide dependency
}
// Missing @EnvironmentObject (pre-iOS 17)
#Preview {
ContentView()
.environmentObject(AppModel())
}
// Missing ModelContainer (SwiftData)
#Preview {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: Item.self, configurations: config)
return ContentView()
.modelContainer(container)
}
// State with invalid defaults
@State var selectedIndex = 10 // ❌ Out of bounds
let items = ["a", "b", "c"]
// Fix: Safe default
@State var selectedIndex = 0 // ✅ Valid index
Verification:
- •Preview loads without errors
- •Can interact with preview normally
- •Changes reflect immediately
Production Crisis Scenario
The Situation
Context:
- •iOS 26 build shipped 2 days ago
- •Users report "settings screen freezes when toggling features"
- •15% of users affected (reported via App Store reviews)
- •VP asking for updates every 2 hours
- •8 hours until next deployment window closes
- •Junior engineer suggests: "Let me try switching to @ObservedObject"
Red Flags — Resist These
If you hear ANY of these under deadline pressure, STOP and use diagnostic patterns:
❌ "Let me try different property wrappers and see what works"
- •Random changes = guessing
- •80% chance of making it worse
❌ "It works on my device, must be iOS 26 bug"
- •User reports are real
- •15% = systematic issue, not edge case
❌ "We can roll back if the fix doesn't work"
- •App Store review takes 24 hours
- •Rollback isn't instant
❌ "Add .id(UUID()) to force refresh"
- •Destroys state preservation
- •Hides root cause
❌ "Users will accept degraded performance for now"
- •Once shipped, you're committed for 24 hours
- •Bad reviews persist
Mandatory Protocol (No Shortcuts)
Total time budget: 90 minutes
Phase 1: Reproduce (15 min)
# 1. Get exact steps from user report # 2. Build Release mode xcodebuild build -scheme YourApp -configuration Release # 3. Test on device (not simulator) # 4. Reproduce freeze 3+ times
If can't reproduce: Ask for video recording or device logs from affected users
Phase 2: Diagnose with Pattern D2 (30 min)
# Launch Instruments with SwiftUI template # Command-I in Xcode # Record while reproducing freeze # Look for: # - Long View Body Updates (red bars) # - Cause & Effect Graph showing update cascade
Find:
- •Which view is expensive?
- •What data change triggered it?
- •How many views updated?
Phase 3: Apply Targeted Fix (20 min)
Based on diagnostic findings:
If Long View Body Update:
// Example finding: Formatter creation in body // Fix: Move to cached formatter
If Cascade Update:
// Example finding: All toggle views reading entire settings array // Fix: Per-toggle view models with granular dependencies
If Environment Issue:
// Example finding: Environment value updating every frame // Fix: Remove from environment, use direct parameter
Phase 4: Verify (15 min)
# Record new Instruments trace # Compare before/after: # - Long updates eliminated? # - Update count reduced? # - Freeze gone? # Test on device 10+ times
Phase 5: Deploy with Evidence (10 min)
Slack to VP + team: "Diagnostic complete: Settings screen freeze caused by formatter creation in ToggleRow body (confirmed via SwiftUI Instrument, Long View Body Updates). Each toggle tap recreated NumberFormatter + DateFormatter for all visible toggles (20+ formatters per tap). Fix: Cached formatters in SettingsViewModel, pre-formatted strings. Verified: Settings screen now responds in <16ms (was 200ms+). Deploying build 2.1.1 now. Will monitor for next 24 hours."
This shows:
- •You diagnosed with evidence (not guessed)
- •You understand the root cause
- •You verified the fix
- •You're shipping with confidence
Time Cost Comparison
Option A: Guess and Pray
- •Time to try random fixes: 30 min
- •Time to deploy: 20 min
- •Time to learn it failed: 24 hours (next App Store review)
- •Total delay: 24+ hours
- •User suffering: Continues through deployment window
- •Risk: Made it worse, now TWO bugs
Option B: Diagnostic Protocol (This Skill)
- •Time to diagnose: 45 min
- •Time to apply targeted fix: 20 min
- •Time to verify: 15 min
- •Time to deploy: 10 min
- •Total time: 90 minutes
- •User suffering: Stopped after 2 hours
- •Confidence: High (evidence-based fix)
Savings: 22 hours + avoid making it worse
When Pressure is Legitimate
Sometimes managers are right to push for speed. Accept the pressure IF:
✅ You've completed diagnostic protocol (90 minutes) ✅ You know exact view/operation causing issue ✅ You have targeted fix, not a guess ✅ You've verified in Instruments before shipping ✅ You're shipping WITH evidence, not hoping
Document your decision (same as above Slack template)
Professional Script for Pushback
If pressured to skip diagnostics:
"I understand the urgency. Skipping diagnostics means 80% chance of shipping the wrong fix, committing us to 24 more hours of user suffering. The diagnostic protocol takes 90 minutes total and gives us evidence-based confidence. We'll have the fix deployed in under 2 hours, verified, with no risk of making it worse. The math says diagnostics is the fastest path to resolution."
Quick Reference Table
| Symptom | Likely Cause | First Check | Pattern | Fix Time |
|---|---|---|---|---|
| View doesn't update | Missing observer / Wrong state | Self._printChanges() | D1 | 10 min |
| View updates too often | Broad dependencies | Self._printChanges() → Instruments | D1 → D2 | 30 min |
| State resets | Identity change | .id() modifiers, conditionals | D3 | 15 min |
| Cascade updates | Environment issue | Environment modifiers | D4 | 20 min |
| Preview crashes | Missing deps / Bad init | Diagnostics button | D5 | 10 min |
| Intermittent issues | Identity or timing | Reproduce 30+ times | D3 | 30 min |
| Long updates (performance) | Expensive body operation | Instruments (SwiftUI + Time Profiler) | D2 | 30 min |
Decision Framework
Before shipping ANY fix:
| Question | Answer Yes? | Action |
|---|---|---|
| Have you used Self._printChanges()? | No | STOP - Pattern D1 (5 min) |
| Have you run SwiftUI Instrument? | No | STOP - Pattern D2 (25 min) |
| Can you explain in one sentence what caused the issue? | No | STOP - you're guessing |
| Have you verified the fix in Instruments? | No | STOP - test before shipping |
| Did you check for simpler explanations? | No | STOP - review diagnostic patterns |
Answer YES to all five → Ship with confidence
Common Mistakes
Mistake 1: "I added @Observable and it fixed it"
Why it's wrong: You don't know WHY it fixed it
- •Might work now, break later
- •Might have hidden another bug
Right approach:
- •Use Pattern D1 (Self._printChanges()) to see BEFORE state
- •Apply @Observable
- •Use Pattern D1 again to see AFTER state
- •Understand exactly what changed
Mistake 2: "Instruments is too slow for quick fixes"
Why it's wrong: Guessing is slower when you're wrong
- •25 min diagnostic = certain fix
- •5 min guess × 3 failed attempts = 15 min + still broken
Right approach:
- •Always profile for production issues
- •Use Self._printChanges() for simple cases
Mistake 3: "The fix works, I don't need to verify"
Why it's wrong: Manual testing ≠ verification
- •Might work for your specific test
- •Might fail for edge cases
- •Might have introduced performance regression
Right approach:
- •Always verify in Instruments after fix
- •Compare before/after traces
- •Test edge cases (empty data, large data, etc.)
Quick Command Reference
Instruments Commands
# Launch Instruments with SwiftUI template # 1. In Xcode: Command-I # 2. Or from command line: open -a Instruments # Build in Release mode (required for accurate profiling) xcodebuild build -scheme YourScheme -configuration Release # Clean derived data if needed rm -rf ~/Library/Developer/Xcode/DerivedData
Self._printChanges() Debug Pattern
// Add temporarily to view body
var body: some View {
let _ = Self._printChanges() // Shows update reason
// Your view code
}
Remember: Remove before committing!
Preview Diagnostics
# Check preview crash logs open ~/Library/Logs/DiagnosticReports/ # Filter for recent preview crashes ls -lt ~/Library/Logs/DiagnosticReports/ | grep -i preview | head -5 # Xcode menu path: # Editor → Canvas → Diagnostics
Environment Search
# Find environment modifiers
grep -r "\.environment(" --include="*.swift" .
# Find environment object usage
grep -r "@Environment" --include="*.swift" .
# Find view identity modifiers
grep -r "\.id(" --include="*.swift" .
Instruments Navigation
In Instruments (after recording):
- •Select SwiftUI track
- •Expand to see:
- •Update Groups lane
- •Long View Body Updates lane
- •Long Representable Updates lane
- •Click Long View Body Updates summary
- •Right-click update → "Set Inspection Range and Zoom"
- •Switch to Time Profiler track
- •Find your view in call stack (Command-F)
Cause & Effect Graph:
- •Expand hierarchy in detail pane
- •Hover over view name → Click arrow
- •Choose "Show Cause & Effect Graph"
- •Click nodes to see:
- •State change node → Backtrace
- •View body node → Dependencies
Resources
WWDC: 2025-306, 2023-10160, 2023-10149, 2021-10022
Docs: /xcode/understanding-hitches-in-your-app, /xcode/analyzing-hangs-in-your-app, /swiftui/managing-model-data-in-your-app
Skills: axiom-swiftui-debugging, axiom-swiftui-performance, axiom-swiftui-layout, axiom-xcode-debugging