Vedran Burojevic
← WritingSeptember 6, 2026

SwiftUI sheets and alerts in production: presentation state without navigation soup

How to model sheets, alerts, confirmation dialogs, and full-screen covers so presentation state stays predictable across platforms and deep links.

On this page

SwiftUI makes presentation easy until the app has more than one thing to present.

The first sheet is usually harmless. A Boolean called showingSettings appears, the view gets a .sheet, and everyone moves on with their lives. Then the screen needs an edit form, an import flow, a paywall, a destructive confirmation, an error alert, a full-screen onboarding step, and a deep link that should open the same edit form from cold launch.

That is when presentation state turns into navigation soup: five Booleans, two optional models, one global router, an alert that sometimes belongs to a different screen, and a full-screen cover that dismisses itself by mutating state three levels above where it was created.

The fix is not a larger coordinator with a heroic name. The fix is treating presentation as product state with identity, ownership, and clear boundaries.

1. Separate navigation from presentation

Navigation and presentation are related, but they are not the same boundary.

Navigation answers: where is the user in the product structure?

Presentation answers: what temporary surface is being shown over the current structure?

A recipe app might navigate from the recipe list to a recipe detail screen. That same detail screen might present:

  • edit recipe
  • add ingredient
  • share sheet
  • delete confirmation
  • import warning
  • paywall
  • sync conflict review

Those are not all destinations in the same sense. Some are transient decisions. Some are modal workflows. Some are system affordances. Some should survive a deep link. Some should disappear when the underlying screen changes.

If every modal becomes another case in the main navigation path, the path stops describing the product hierarchy. If every modal is a local Boolean, the app loses the ability to coordinate deep links, restoration, tests, and dismissal rules.

Use two layers:

struct RecipeScreenState {
    var navigationPath: [RecipeRoute] = []
    var presentation: RecipePresentation?
}
 
enum RecipeRoute: Hashable {
    case recipeDetail(Recipe.ID)
    case collection(Collection.ID)
}
 
enum RecipePresentation: Identifiable, Hashable {
    case editRecipe(Recipe.ID)
    case addIngredient(Recipe.ID)
    case shareRecipe(Recipe.ID)
    case deleteRecipe(Recipe.ID)
 
    var id: String {
        switch self {
        case .editRecipe(let id): "editRecipe-\(id)"
        case .addIngredient(let id): "addIngredient-\(id)"
        case .shareRecipe(let id): "shareRecipe-\(id)"
        case .deleteRecipe(let id): "deleteRecipe-\(id)"
        }
    }
}

The navigation path tells you where the user is. The presentation value tells you what is temporarily active on top of that place.

That separation keeps the model understandable when the app grows. It also stops the main router from becoming a junk drawer for every alert and sheet the codebase ever regretted creating.

2. Replace Boolean piles with one presentation value

Multiple presentation Booleans are cheap to write and expensive to reason about.

This shape is fine for a prototype:

@State private var showingEdit = false
@State private var showingShare = false
@State private var showingDeleteAlert = false
@State private var showingImportSheet = false

In production, it has no clear answer to basic questions:

  1. What happens if two values become true in the same update?
  2. Which presentation wins?
  3. Which one should dismiss first?
  4. Can this state be restored?
  5. Can a test assert the current presentation without inspecting four fields?

Use one optional enum for mutually exclusive presentation on a surface:

@State private var presentation: RecipePresentation?
 
.sheet(item: $presentation.editBinding) { presentation in
    switch presentation {
    case .editRecipe(let id):
        EditRecipeView(recipeID: id)
    case .addIngredient(let id):
        AddIngredientView(recipeID: id)
    default:
        EmptyView()
    }
}
.alert(item: $presentation.alertBinding) { presentation in
    makeAlert(for: presentation)
}

The exact binding helpers depend on the app. The important rule is simpler: one screen should have one presentation decision at a time unless the product explicitly supports stacking.

That gives the state model a useful invariant:

For this screen, active presentation is either nil or exactly one named case.

That invariant is boring. Good. Boring invariants are how UI code survives redesigns without developing paranormal behavior.

3. Use item-driven sheets when identity matters

Boolean sheets are useful when the sheet has no identity.

For anything tied to a model, prefer item-driven presentation:

enum LibraryPresentation: Identifiable {
    case editSnippet(Snippet.ID)
    case moveSnippet(Snippet.ID)
    case importPreview(ImportBatch.ID)
 
    var id: String {
        switch self {
        case .editSnippet(let id): "editSnippet-\(id)"
        case .moveSnippet(let id): "moveSnippet-\(id)"
        case .importPreview(let id): "importPreview-\(id)"
        }
    }
}

Then the sheet is driven by the item:

.sheet(item: $presentation) { presentation in
    switch presentation {
    case .editSnippet(let id):
        EditSnippetScreen(snippetID: id)
    case .moveSnippet(let id):
        MoveSnippetScreen(snippetID: id)
    case .importPreview(let id):
        ImportPreviewScreen(batchID: id)
    }
}

This avoids a common bug: a Boolean sheet opens while the selected model changes underneath it. The user taps edit on row A, a refresh changes selection to row B, and now the modal is editing the wrong object. Delightful, if your product goal is witness confusion.

Identity belongs in the presentation value. Pass identifiers, not large mutable model objects. Resolve the latest data inside the presented screen.

That gives the app a chance to handle reality:

  • the object may have been deleted
  • sync may have updated the object
  • permissions may no longer allow the action
  • the destination may need a loading or recovery state

A sheet tied to Snippet.ID can respond to those conditions. A sheet tied to whatever object happened to be captured during a view update is guessing with nicer syntax.

4. Keep alerts small and product-specific

Alerts are for short decisions, not miniature workflows.

Use alerts for states like:

  • discard unsaved changes
  • confirm a simple destructive action
  • explain why a one-step action failed
  • ask whether to retry a narrow operation

Do not use alerts for:

  • comparing two versions of data
  • fixing account or permission setup
  • explaining a complex sync failure
  • selecting from many options
  • repairing a broken import
  • showing content the user needs to read carefully

SwiftUI makes .alert convenient. That does not mean the product should cram every uncomfortable state into it.

A delete confirmation can be an alert:

enum RecipeAlert: Identifiable {
    case deleteRecipe(Recipe.ID)
    case discardChanges
 
    var id: String {
        switch self {
        case .deleteRecipe(let id): "deleteRecipe-\(id)"
        case .discardChanges: "discardChanges"
        }
    }
}

A sync conflict review should not be an alert. It needs comparison, explanation, undo, and possibly diagnostics. That belongs in a sheet, detail screen, or dedicated review flow.

When in doubt, ask one question: can the user make a good decision from two lines of text and two buttons?

If not, the alert is hiding product work. Hiding product work is a reliable way to make users do it badly.

5. Use confirmation dialogs for choices, not data loss theatre

confirmationDialog is useful when the user is choosing from a small set of related actions.

Good cases:

  • choose export format
  • pick a share target inside the app
  • select a sorting mode
  • choose where to move an item
  • confirm a contextual action on iPhone where an action sheet fits the platform

Be careful with destructive choices. A confirmation dialog can make danger look too lightweight, especially if the action destroys data or changes account state.

For destructive actions, define the risk explicitly:

enum TripDialog: Identifiable {
    case tripActions(Trip.ID)
    case exportFormat(Trip.ID)
 
    var id: String {
        switch self {
        case .tripActions(let id): "tripActions-\(id)"
        case .exportFormat(let id): "exportFormat-\(id)"
        }
    }
}

Then keep the dialog scoped:

.confirmationDialog(
    "Trip Actions",
    item: $dialog
) { dialog in
    switch dialog {
    case .tripActions(let tripID):
        Button("Duplicate") { duplicateTrip(tripID) }
        Button("Archive") { archiveTrip(tripID) }
        Button("Delete", role: .destructive) {
            presentation = .deleteTrip(tripID)
        }
    case .exportFormat(let tripID):
        Button("PDF") { exportTrip(tripID, as: .pdf) }
        Button("Text") { exportTrip(tripID, as: .plainText) }
    }
}

Notice the delete action does not delete directly from the dialog. It moves to a clearer confirmation path when the product risk is high enough.

That is not ceremony. That is refusing to turn data loss into a tiny menu item with confidence issues.

6. Model dismissal as an operation

Dismissal is not always a view concern.

Sometimes the presented screen can dismiss itself after a successful action. Sometimes dismissal should be blocked because there are unsaved changes. Sometimes dismissal should trigger cleanup, analytics, a refresh, or a continuation into another presentation.

Do not smear that policy across random calls to dismiss().

A better pattern is to make the presented flow report an outcome:

enum EditRecipeOutcome {
    case saved(Recipe.ID)
    case cancelled
    case requestedDelete(Recipe.ID)
}
 
struct EditRecipeScreen: View {
    let recipeID: Recipe.ID
    let onFinish: (EditRecipeOutcome) -> Void
 
    var body: some View {
        EditRecipeForm(
            recipeID: recipeID,
            onSave: { onFinish(.saved(recipeID)) },
            onCancel: { onFinish(.cancelled) },
            onDelete: { onFinish(.requestedDelete(recipeID)) }
        )
    }
}

The parent owns the transition:

EditRecipeScreen(recipeID: id) { outcome in
    switch outcome {
    case .saved:
        presentation = nil
        reloadVisibleRecipe()
    case .cancelled:
        presentation = nil
    case .requestedDelete(let id):
        presentation = .deleteRecipe(id)
    }
}

This keeps dismissal and follow-up presentation in one place. It also gives tests something clean to assert.

The presented view should not need to know whether it came from a list, deep link, widget handoff, or restored session. It should finish with a domain outcome. The owner decides what happens next.

7. Decide which presentation state survives restoration

Not every modal should survive app restart.

Some presentations are durable product state:

  • editing a draft
  • onboarding step
  • conflict review
  • import preview
  • checkout or subscription recovery

Others are temporary interface state:

  • share sheet
  • quick action dialog
  • transient error alert
  • contextual menu
  • one-time confirmation after a tap

Treat those differently.

A durable presentation should be restorable from identifiers and domain state:

struct RestorablePresentation: Codable, Hashable {
    var kind: Kind
    var objectID: UUID?
 
    enum Kind: String, Codable {
        case editDraft
        case importPreview
        case conflictReview
    }
}

A transient presentation should usually disappear when the scene is rebuilt. Restoring an old delete confirmation after launch is rarely helpful. It can even be dangerous if the underlying state has changed.

Deep links follow the same rule. A deep link can request a presentation, but the app should validate it before showing anything:

  1. route to the owning screen
  2. resolve the target object
  3. check permissions and current state
  4. present the modal only if the action is still valid
  5. show a recovery screen if the target is gone or unavailable

Do not let URLs mutate random presentation Booleans from outside the feature. That is not deep linking. That is remote-controlled soup.

8. Respect platform differences without forking the model

Sheets, popovers, alerts, confirmation dialogs, and full-screen covers behave differently across iPhone, iPad, and Mac.

The product state can stay shared. The presentation adapter can vary.

For example:

enum SettingsPresentation: Hashable, Identifiable {
    case account
    case privacy
    case advanced
 
    var id: Self { self }
}

On iPhone, account settings may be a sheet. On iPad, it may be a form sheet. On Mac, it may open the Settings scene or a separate window. The domain intent is the same: show account settings.

Keep that split explicit:

func presentSettings(_ destination: SettingsPresentation) {
    #if os(macOS)
    openSettingsWindow(destination)
    #else
    presentation = .settings(destination)
    #endif
}

Avoid duplicating the whole feature model because one platform wants a different container. That creates two versions of the same product behavior with slightly different bugs.

Also avoid pretending every platform should use the same container. A full-screen cover that makes sense for onboarding on iPhone may feel heavy on iPad and bizarre on Mac. Platform conventions are not decoration. They are part of how users understand state.

9. Keep presentation out of domain services

Domain services should not present UI.

This sounds too obvious to say, which means it is exactly where codebases drift.

A sync service should report:

enum SyncOperationResult {
    case saved
    case needsConflictReview(SyncConflict.ID)
    case accountUnavailable
    case failed(SyncFailure)
}

It should not decide whether the app shows an alert, sheet, banner, window, or push notification. That choice belongs to the presentation layer that understands platform, scene, and user context.

The view model or reducer can translate domain results into presentation state:

func handleSyncResult(_ result: SyncOperationResult) {
    switch result {
    case .saved:
        banner = .saved
    case .needsConflictReview(let conflictID):
        presentation = .conflictReview(conflictID)
    case .accountUnavailable:
        presentation = .icloudAccountIssue
    case .failed(let failure):
        alert = .syncFailed(failure.recoverySuggestion)
    }
}

That translation is valuable. It is where product judgment lives.

If a service imports SwiftUI because it wants to present an alert, the architecture is filing a missing-person report for its boundaries.

10. Test presentation as state, not tap choreography

Presentation bugs are easy to miss because manual testing follows the happy path.

A useful test suite should assert the state transitions directly:

  1. tapping Edit sets presentation = .editRecipe(id)
  2. saving from the edit flow clears presentation and refreshes the detail
  3. requesting delete from edit moves to .deleteRecipe(id)
  4. deep link to edit validates the object before presentation
  5. deleting the object while edit is open produces a recovery state
  6. opening share while an alert is active follows the defined priority
  7. scene restoration restores only durable presentations
  8. iPad and Mac adapters map the same product intent to platform-appropriate surfaces

You do not need UI tests for all of that. Most of it belongs in reducer, view-model, or coordinator tests where presentation is a value.

Use UI tests for the platform contract:

  • sheet appears with the expected title
  • alert buttons are labeled correctly
  • destructive roles are wired
  • Escape or swipe dismissal behaves as intended
  • focus lands in the right field
  • deep link opens the right surface from cold launch

The lower-level tests prove the state machine. The UI tests prove SwiftUI and the platform adapter honor it.

Do both where the risk justifies it. A compile passing is not evidence that modal presentation works. It is merely evidence that the compiler has not joined the incident yet.

11. The production baseline

For a real SwiftUI app, I want this baseline:

  1. presentation modeled as one optional enum per owning surface
  2. identifiers stored in presentation state instead of captured mutable models
  3. alerts reserved for narrow decisions and failures
  4. dialogs used for small action sets, not complex recovery
  5. dismissal modeled through outcomes when follow-up behavior matters
  6. restoration limited to presentations that still make product sense after restart
  7. deep links validated before they present anything
  8. platform-specific containers behind shared product intent
  9. domain services returning results, not presenting UI
  10. tests that assert presentation transitions as values

That is enough structure for most apps. Anything heavier should earn its keep with real complexity, not coordinator cosplay.

SwiftUI presentation works best when it is boringly explicit. Name the temporary surfaces. Give them identity. Decide who owns them. Keep navigation, domain operations, and platform containers separated.

The reward is not architecture purity. The reward is an app where sheets, alerts, dialogs, and full-screen covers stop surprising the user, the tests, and the engineer unlucky enough to touch the feature six months later.

Command menu

Navigate the site or run an action