The Problem (Again!)#
In my previous post, I covered how using .sheet(item:) solves the classic nil-value race condition in SwiftUI sheets. But recently, while working on the wishlist feature in my AllowanceIQ app, I discovered that even .sheet(item:) can have race conditions if you’re not careful.
Here’s what my “improved” code looked like after applying the lessons from my first post:
@State private var selectedWishlistItem: WishlistItemDomain?
@State private var showingWishlistDetail = false
@State private var showingEditWishlistItem = false
// Button taps
WishlistItemCard(
onTap: {
selectedWishlistItem = item
showingWishlistDetail = true
},
onEdit: {
selectedWishlistItem = item
showingEditWishlistItem = true
}
)
// Single sheet with conditional logic
.sheet(item: $selectedWishlistItem) { item in
if showingEditWishlistItem {
EditWishlistItemView(wishlistItem: item)
} else if showingWishlistDetail {
WishlistDetailView(wishlistItem: item)
}
}
Looks reasonable, right? I’m using .sheet(item:) as recommended, and the item is guaranteed to be non-nil. But users reported that tapping on wishlist items sometimes showed a blank sheet!
The Subtle Race Condition#
Here’s what was happening:
- User taps a wishlist item card
selectedWishlistItem = item(triggers sheet presentation immediately)- Sheet closure executes with current boolean state:
showingWishlistDetail = false showingWishlistDetail = true(but it’s too late - sheet already rendered)- Result: Neither condition is true, so the sheet shows nothing
Even though we’re using .sheet(item:), the conditional logic inside the sheet closure is still subject to state timing issues.
The Solution: Multiple Dedicated Sheets#
The fix is to abandon conditional logic inside sheets entirely and use dedicated sheets for each use case:
@State private var selectedWishlistItemForDetail: WishlistItemDomain?
@State private var selectedWishlistItemForEdit: WishlistItemDomain?
// Simplified button callbacks
WishlistItemCard(
onTap: {
selectedWishlistItemForDetail = item
},
onEdit: {
selectedWishlistItemForEdit = item
}
)
// Two dedicated sheets - no conditional logic!
.sheet(item: $selectedWishlistItemForDetail) { item in
WishlistDetailView(wishlistItem: item)
}
.sheet(item: $selectedWishlistItemForEdit) { item in
EditWishlistItemView(wishlistItem: item)
}
What changed:
- Each sheet has its own dedicated state variable
- No boolean flags or conditional logic inside sheet closures
- Each callback sets exactly one state variable
- SwiftUI handles the rest automatically
Why This Pattern is Superior#
✅ Reliability#
- No race conditions between multiple state updates
- Each sheet has a single, clear trigger condition
- State changes are atomic and predictable
✅ Clarity#
- Intent is obvious: “this state shows detail sheet, that state shows edit sheet”
- No complex conditional logic to debug
- Easy to reason about sheet presentation logic
✅ Maintainability#
- Adding new sheet types doesn’t complicate existing logic
- Each sheet’s state is independent
- Easier to test and debug individual flows
✅ Performance#
- SwiftUI can optimize sheet presentation more effectively
- No unnecessary condition evaluations on every state change
- Cleaner view invalidation patterns
The Anti-Pattern to Avoid#
// ❌ DON'T DO THIS - Conditional logic in sheet(item:)
.sheet(item: $selectedItem) { item in
if someBoolean {
ViewA(item: item)
} else if anotherBoolean {
ViewB(item: item)
} else {
ViewC(item: item)
}
}
// ✅ DO THIS - Dedicated sheets
.sheet(item: $selectedItemForA) { item in
ViewA(item: item)
}
.sheet(item: $selectedItemForB) { item in
ViewB(item: item)
}
.sheet(item: $selectedItemForC) { item in
ViewC(item: item)
}
Updated Best Practices#
Building on my previous post, here’s the complete guidance:
Level 1: Basic Sheet with Data#
// ✅ Use sheet(item:) instead of isPresented + separate state
.sheet(item: $selectedItem) { item in
DetailView(item: item)
}
Level 2: Multiple Sheet Types#
// ✅ Use dedicated state variables, not conditional logic
.sheet(item: $itemForDetail) { item in DetailView(item: item) }
.sheet(item: $itemForEdit) { item in EditView(item: item) }
.sheet(item: $itemForShare) { item in ShareView(item: item) }
Level 3: Complex Flows#
// ✅ Consider navigation or coordinator patterns for complex flows
@State private var navigationPath = NavigationPath()
NavigationStack(path: $navigationPath) {
// Your main view
}
Updated Summary Table#
| Pattern | Reliability | Clarity | When to Use |
|---|---|---|---|
.sheet(isPresented:) with data | ❌ Race-prone | ❌ Confusing | Never for data passing |
.sheet(item:) with conditionals | ⚠️ Race-prone | ⚠️ Complex | Never |
Multiple .sheet(item:) | ✅ Reliable | ✅ Clear | Most cases |
| NavigationStack | ✅ Reliable | ✅ Clear | Complex flows |
TL;DR#
Even with .sheet(item:), avoid conditional logic inside the sheet closure. Instead, use multiple dedicated state variables and separate .sheet(item:) modifiers. It’s more reliable, clearer, and follows SwiftUI’s intended patterns.
The rule is simple: One sheet type = One state variable = One .sheet() modifier
Part of a series on SwiftUI state management pitfalls. See Part 1 for the basics of .sheet(item:) vs .sheet(isPresented:).
Happy (race-condition-free) coding!
