Skip to main content
Featured image for SwiftUI Sheet Pitfall Part 2: Why Conditional Logic Inside sheet(item:) Still Fails

SwiftUI Sheet Pitfall Part 2: Why Conditional Logic Inside sheet(item:) Still Fails

·727 words·4 mins

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:

  1. User taps a wishlist item card
  2. selectedWishlistItem = item (triggers sheet presentation immediately)
  3. Sheet closure executes with current boolean state: showingWishlistDetail = false
  4. showingWishlistDetail = true (but it’s too late - sheet already rendered)
  5. 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
#

PatternReliabilityClarityWhen to Use
.sheet(isPresented:) with data❌ Race-prone❌ ConfusingNever for data passing
.sheet(item:) with conditionals⚠️ Race-prone⚠️ ComplexNever
Multiple .sheet(item:)✅ Reliable✅ ClearMost cases
NavigationStack✅ Reliable✅ ClearComplex 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!