SwiftUI NavigationStack patterns for predictable multi-step flows
SwiftUI NavigationStack patterns for multi-step flows, with clear routing, safe back behavior, and practical examples for onboarding and approval wizards.

What goes wrong in multi-step flows
A multi-step flow is any sequence where step 1 needs to happen before step 2 makes sense. Common examples include onboarding, an approval request (review, confirm, submit), and wizard-style data entry where someone builds a draft across multiple screens.
These flows feel easy only when Back behaves the way people expect. If Back takes them somewhere surprising, users stop trusting the app. That shows up as wrong submissions, abandoned onboarding, and support tickets like, “I can’t get back to the screen I was on.”
Messy navigation usually looks like one of these:
- The app jumps to the wrong screen, or exits the flow too early.
- The same screen appears twice because it was pushed twice.
- A step resets on Back and the user loses their draft.
- The user can reach step 3 without completing step 1, creating invalid state.
- After a deep link or app restart, the app shows the right screen but with the wrong data.
A useful mental model: a multi-step flow is two things moving together.
First, a stack of screens (what the user can go back through). Second, shared flow state (draft data and progress that should not vanish just because a screen disappears).
Many NavigationStack setups fall apart when the screen stack and the flow state drift apart. For example, an onboarding flow might push “Create profile” twice (duplicate routes), while the draft profile lives inside the view and gets recreated on re-render. The user hits Back, sees a different version of the form, and assumes the app is unreliable.
Predictable behavior starts with naming the flow, defining what Back should do at each step, and giving flow state one clear home.
NavigationStack basics you actually need
For multi-step flows, use NavigationStack rather than the older NavigationView. NavigationView can behave differently across iOS versions and is harder to reason about when you push, pop, or restore screens. NavigationStack is the modern API that treats navigation like a real stack.
A NavigationStack stores a history of where the user has been. Each push adds a destination to the stack. Each back action pops one destination. That simple rule is what makes a flow feel stable: the UI should mirror a clear sequence of steps.
What the stack really holds
SwiftUI isn’t storing your view objects. It stores the data you used to navigate (your route value) and uses that to rebuild the destination view when needed. That has a few practical consequences:
- Don’t rely on a view staying alive to keep important data.
- If a screen needs state, keep it in a model (like an ObservableObject) that lives outside the pushed view.
- If you push the same destination twice with different data, SwiftUI treats them as different stack entries.
NavigationPath is what you reach for when your flow isn’t just one or two fixed pushes. Think of it as an editable list of “where we are going” values. You can append routes to move forward, remove the last route to go back, or replace the whole path to jump to a later step.
It’s a good fit when you need wizard-style steps, need to reset the flow after completion, or want to restore a partial flow from saved state.
Predictable beats clever. Fewer hidden rules (automatic jumps, implicit pops, view-driven side effects) means fewer strange back stack bugs later.
Model the flow with a small route enum
Predictable navigation starts with one decision: keep routing in one place, and make every screen in the flow a small, clear value.
Create a single source of truth, such as a FlowRouter (an ObservableObject) that owns the NavigationPath. That keeps every push and pop consistent, instead of scattering navigation across views.
A simple router structure
Use an enum to represent steps. Add associated values only for lightweight identifiers (like IDs), not whole models.
enum Step: Hashable {
case welcome
case profile
case verifyCode(phoneID: UUID)
case review(applicationID: UUID)
case done
}
final class FlowRouter: ObservableObject {
@Published var path = NavigationPath()
func go(_ step: Step) { path.append(step) }
func back() { if !path.isEmpty { path.removeLast() } }
func reset() { path = NavigationPath() }
}
Keep flow state separate from navigation state
Treat navigation as “where the user is,” and flow state as “what they’ve entered so far.” Put flow data in its own store (for example, OnboardingState with name, email, uploaded documents) and keep it stable while screens come and go.
A simple rule of thumb:
FlowRouter.pathcontains onlyStepvalues.OnboardingStatecontains the user’s inputs and draft data.- Steps carry IDs to look up data, not the data itself.
This avoids fragile hashing, huge paths, and surprise resets when SwiftUI rebuilds views.
Step by step: build a wizard with NavigationPath
For wizard-style screens, the simplest approach is controlling the stack yourself. Aim for one source of truth for “where am I in the flow?” and one way to move forward or back.
Start with a NavigationStack(path:) bound to a NavigationPath. Each pushed screen is represented by a value (often an enum case), and you register destinations once.
import SwiftUI
enum WizardRoute: Hashable {
case profile
case verifyEmail
case permissions
case review
}
struct OnboardingWizard: View {
@State private var path = NavigationPath()
@State private var currentIndex = 0
private let steps: [WizardRoute] = [.profile, .verifyEmail, .permissions, .review]
var body: some View {
NavigationStack(path: $path) {
StartScreen {
goToStep(0) // push first step
}
.navigationDestination(for: WizardRoute.self) { route in
switch route {
case .profile:
ProfileStep(onNext: { goToStep(1) })
case .verifyEmail:
VerifyEmailStep(onNext: { goToStep(2) })
case .permissions:
PermissionsStep(onNext: { goToStep(3) })
case .review:
ReviewStep(onEditProfile: { popToStep(0) })
}
}
}
}
private func goToStep(_ index: Int) {
currentIndex = index
path.append(steps[index])
}
private func popToStep(_ index: Int) {
let toRemove = max(0, currentIndex - index)
if toRemove > 0 { path.removeLast(toRemove) }
currentIndex = index
}
}
To keep Back predictable, stick to a few habits. Append exactly one route to advance, keep “Next” linear (only push the next step), and when you need to jump back (like “Edit profile” from Review), trim the stack to a known index.
This avoids accidental duplicate screens and makes Back match what users expect: one tap equals one step.
Keep data stable while screens come and go
A multi-step flow feels unreliable when each screen owns its own state. You type a name, go forward, go back, and the field is empty because the view was recreated.
The fix is straightforward: treat the flow as one draft object, and let each step edit it.
In SwiftUI, that usually means a shared ObservableObject created once at the start of the flow and passed to every step. Don’t store draft values in each view’s @State unless they truly belong only to that screen.
final class OnboardingDraft: ObservableObject {
@Published var fullName = ""
@Published var email = ""
@Published var wantsNotifications = false
var canGoNextFromProfile: Bool {
!fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& email.contains("@")
}
}
Create it at the entry point, then share it with @StateObject and @EnvironmentObject (or pass it explicitly). Now the stack can change without losing data.
Decide what survives back navigation
Not everything should persist forever. Decide your rules up front so the flow stays consistent.
Keep user input (text fields, toggles, selections) unless they explicitly reset. Reset step-specific UI state (loading spinners, temporary alerts, short animations). Clear sensitive fields (like one-time codes) when leaving that step. If a choice changes later steps, clear only the dependent fields.
Validation fits naturally here. Instead of letting users go forward and then showing an error on the next screen, keep them on the current step until it’s valid. Disabling the button based on a computed property like canGoNextFromProfile is often enough.
Save checkpoints without overdoing it
Some drafts can live only in memory. Others should survive app restarts or a crash. A practical default:
- Keep data in memory while the user is actively moving through steps.
- Persist locally at clear milestones (account created, approval submitted, payment started).
- Persist earlier if the flow is long or data entry takes more than a minute.
That way, screens can come and go freely, and the user’s progress still feels stable and respectful of their time.
Deep links and restoring a partially finished flow
Deep links matter because real flows rarely start at step 1. Someone taps an email, a push notification, or a shared link and expects to land on the right screen, like step 3 of onboarding or the final approval screen.
With NavigationStack, treat a deep link as instructions to build a valid path, not a command to jump to one view. Start from the beginning of the flow and append only the steps that are true for this user and this session.
Turn an external link into a safe route sequence
A good pattern is: parse the external ID, load the minimal data you need, then convert it into a sequence of routes.
enum Route: Hashable {
case start
case profile
case verifyEmail
case approve(requestID: String)
}
func pathForDeepLink(requestID: String, hasProfile: Bool, emailVerified: Bool) -> [Route] {
var routes: [Route] = [.start]
if !hasProfile { routes.append(.profile) }
if !emailVerified { routes.append(.verifyEmail) }
routes.append(.approve(requestID: requestID))
return routes
}
Those checks are your guardrails. If prerequisites are missing, don’t drop the user onto step 3 with an error and no way forward. Send them to the first missing step, and make sure the back stack still tells a coherent story.
Restoring a partially finished flow
To restore after relaunch, save two things: the last known route state and the user-entered draft data. Then decide how to resume without surprising people.
If the draft is fresh (minutes or hours), offer a clear “Resume” choice. If it’s old, start from the beginning but keep the draft to prefill fields. If requirements changed, rebuild the path using the same guardrails.
Push vs modal: keep the flow easy to exit
A flow feels predictable when there’s one main way forward: pushing screens on a single stack. Use sheets and full-screen covers for side tasks, not for the main path.
Push (NavigationStack) fits when the user expects Back to retrace their steps. Modals (sheet or fullScreenCover) fit when the user is doing a side task, making a quick choice, or confirming a risky action.
A simple set of rules prevents most navigation weirdness:
- Push for the main path (Step 1, Step 2, Step 3).
- Use a sheet for small optional tasks (pick a date, choose a country, scan a document).
- Use fullScreenCover for “separate worlds” (login, camera capture, a long legal document).
- Use a modal for confirmations (cancel flow, delete draft, submit for approval).
The common mistake is putting main flow screens into sheets. If Step 2 is a sheet, the user can dismiss it with a swipe, lose context, and end up with a stack that says they’re on Step 1 while their data says they finished Step 2.
Confirmations are the opposite: pushing a “Are you sure?” screen into the wizard clutters the stack and can create loops (Step 3 -> Confirm -> Back -> Step 3 -> Back -> Confirm).
How to close everything cleanly after “Done”
Decide what “Done” means first: return to the home screen, return to the list, or show a success screen.
If the flow is pushed, reset your NavigationPath to empty to pop back to the start. If the flow is presented modally, call dismiss() from the environment. If you have both (a modal containing a NavigationStack), dismiss the modal, not each pushed screen. After a successful submit, also clear any draft state so a reopened flow starts fresh.
Back button behavior and “Are you sure?” moments
For most multi-step flows, the best move is to do nothing: let the system back button (and swipe-back gesture) work. It matches user expectations and avoids bugs where the UI says one thing but navigation state says another.
Interception is only worth it when going back would cause real harm, like losing a long unsaved form or abandoning an irreversible action. If the user can safely return and continue, don’t add friction.
A practical approach is to keep system navigation, but add a confirmation only when the screen is “dirty” (edited). That means providing your own back action and asking once, with a clear way out.
@Environment(\.dismiss) private var dismiss
@State private var showLeaveConfirm = false
let hasUnsavedChanges: Bool
var body: some View {
Form { /* fields */ }
.navigationBarBackButtonHidden(hasUnsavedChanges)
.toolbar {
if hasUnsavedChanges {
ToolbarItem(placement: .navigationBarLeading) {
Button("Back") { showLeaveConfirm = true }
}
}
}
.confirmationDialog("Discard changes?", isPresented: $showLeaveConfirm) {
Button("Discard", role: .destructive) { dismiss() }
Button("Keep Editing", role: .cancel) {}
}
}
Keep this from turning into a trap:
- Ask only when you can explain the consequence in one short sentence.
- Offer a safe option (Cancel, Keep Editing) plus a clear exit (Discard, Leave).
- Don’t hide back buttons unless you replace them with an obvious Back or Close.
- Prefer confirming the irreversible action (like “Approve”) instead of blocking navigation everywhere.
If you find yourself fighting the back gesture often, that’s usually a sign the flow needs autosave, a saved draft, or smaller steps.
Common mistakes that create weird back stacks
Most “why did it go back there?” bugs aren’t SwiftUI being random. They usually come from patterns that make navigation state unstable. For predictable behavior, treat the back stack like app data: stable, testable, and owned by one place.
Accidental extra stacks
A common trap is ending up with more than one NavigationStack without realizing it. For example, each tab has its own root stack, and then a child view adds another stack inside the flow. The result is confusing back behavior, missing navigation bars, or screens that don’t pop the way you expect.
Another frequent issue is recreating your NavigationPath too often. If the path is created inside a view that re-renders, it can reset on state changes and jump the user back to step 1 after they typed into a field.
The mistakes behind most weird stacks are straightforward:
- Nesting NavigationStack inside another stack (often inside tabs or sheet content)
- Re-initializing
NavigationPath()during view updates instead of keeping it in long-lived state - Putting non-stable values in your route (like a model object that changes), which breaks
Hashableand causes mismatched destinations - Scattering navigation decisions across button handlers until no one can explain what “next” means
- Driving the flow from multiple sources at once (for example, both a view model and a view mutating the path)
If you need to pass data between steps, prefer stable identifiers in the route (IDs, step enums) and keep the actual form data in shared state.
A concrete example: if your route is .profile(User) and User changes as the person types, SwiftUI can treat it as a different route and rewire the stack. Make the route .profile and store the draft profile data in shared state.
Quick checklist for predictable navigation
When a flow feels off, it’s usually because the back stack isn’t telling the same story as the user. Before polishing the UI, do a quick pass over your navigation rules.
Test on a real device, not only previews, and try both slow and fast taps. Fast taps often reveal duplicate pushes and missing state.
- Go back one step at a time from the last screen to the first. Confirm every screen shows the same data the user entered earlier.
- Trigger Cancel from every step (including the first and the last). Confirm it always returns to a sensible place, not a random prior screen.
- Force quit mid-flow and relaunch. Make sure you can resume safely, either by restoring the path or by restarting at a known step with saved data.
- Open the flow using a deep link or app shortcut. Verify the destination step is valid; if required data is missing, redirect to the earliest step that can collect it.
- Finish with Done and confirm the flow is removed cleanly. The user shouldn’t be able to hit Back and re-enter a completed wizard.
A simple way to test: imagine an onboarding wizard with three screens (Profile, Permissions, Confirm). Enter a name, go forward, go back, edit it, then jump to Confirm via a deep link. If Confirm shows the old name, or if Back takes you to a duplicate Profile screen, your path updates aren’t consistent.
If you can pass the checklist without surprises, your flow will feel calm and predictable, even when users leave and return later.
A realistic example and next steps
Picture a manager approval flow for an expense request. It has four steps: Review, Edit, Confirm, and Receipt. The user expects one thing: Back always goes to the previous step, not to some random screen they visited earlier.
A simple route enum keeps this predictable. Your NavigationPath should store only the route and any small identifiers needed to reload state, such as an expenseID and a mode (review vs edit). Avoid pushing large, mutable models into the path because it makes restores and deep links fragile.
Keep the working draft in a single source of truth outside the views, such as an @StateObject flow model (or a store). Each step reads and writes that model, so screens can appear and disappear without losing inputs.
At a minimum, you’re tracking three things:
- Routes (for example:
review(expenseID),edit(expenseID),confirm(expenseID),receipt(expenseID)) - Data (a draft object with line items and notes, plus a status like
pending,approved,rejected) - Location (draft in your flow model, canonical record on the server, and a small restore token locally: expenseID + last step)
Edge cases are where flows either earn trust or lose it. If the manager rejects in Confirm, decide whether Back returns to Edit (to fix) or exits the flow. If they return later, restore the last step from the saved token and reload the draft. If they switch devices, treat the server as truth: reconstruct the path from server status and send them to the right step.
Next steps: document your route enum (what each case means and when it’s used), add a couple basic tests for path building and restore behavior, and stick to one rule: views don’t own navigation decisions.
If you’re building the same kind of multi-step flows without writing everything from scratch, platforms like AppMaster (appmaster.io) apply the same separation: keep step navigation and business data separate so screens can change without breaking the user’s progress.
FAQ
Use NavigationStack with a single NavigationPath you control. Push exactly one route per “Next” action and pop exactly one route per Back action. When you need a jump (like “Edit profile” from Review), trim the path to a known step instead of pushing more screens.
Because SwiftUI rebuilds destination views from the route value, not from a preserved view instance. If your form data lives in the view’s @State, it can reset when the view is recreated. Put draft data in a shared model (like an ObservableObject) that lives outside the pushed views.
It usually happens when you append the same route more than once (often due to fast taps or multiple code paths triggering navigation). Disable the Next button while you’re navigating or while validation/loading runs, and keep navigation mutations in one place so only one append happens per step.
Keep routing values small and stable, like an enum case plus lightweight IDs. Store mutable data (the draft) in a separate shared object and look it up by ID if needed. Pushing large, changing models into the path can break Hashable expectations and cause mismatched destinations.
Navigation is “where the user is,” and flow state is “what they’ve entered.” Own the navigation path in a router (or one top-level state) and own the draft in a separate ObservableObject. Each screen edits the draft; the router only changes steps.
Treat a deep link as instructions to build a valid sequence of steps, not a teleport to one screen. Build the path by appending required prerequisite steps first (based on what the user has completed), then append the target step. This keeps the back stack coherent and avoids invalid state.
Save two things: the last meaningful route (or step identifier) and the draft data. On relaunch, rebuild the path using the same prerequisite checks you use for deep links, then load the draft. If the draft is old, restarting the flow but prefilling fields is often less surprising than dropping the user mid-wizard.
Push screens for the main step-by-step path so Back retraces the flow naturally. Use sheets for optional side tasks and fullScreenCover for separate experiences like login or camera capture. Avoid putting core steps in modals because dismiss gestures can desync the UI from the flow state.
Don’t intercept Back by default; let the system behavior work. Add a confirmation only when leaving would lose meaningful unsaved work, and only when the screen is actually “dirty.” Prefer autosave or draft persistence when you find yourself needing confirmations frequently.
Common causes are nesting multiple NavigationStacks, recreating NavigationPath during view updates, and having multiple owners mutate the path. Keep one stack per flow, keep the path in long-lived state (@StateObject or a single router), and centralize all push/pop logic to one place.


