Est.

Composable Navigation in SwiftUI With NavigationStack and Coordinator Pattern

A pattern for keeping navigation state separate from views in SwiftUI.

Features Editor · · 11 min read
Cover illustration for “Composable Navigation in SwiftUI With NavigationStack and Coordinator Pattern”
Swift Architecture · September 15, 2026 · 11 min read · 2,505 words

Navigation in SwiftUI looks trivial for about a week. A few NavigationLinks, some @State booleans, screens push and pop, everyone's happy. Then the onboarding flow needs to run standalone, a push notification needs to deep link three screens deep, a tab bar needs custom behavior, and a sheet needs to layer over an existing stack. That's when the wiring most teams shipped in week one starts to come apart at the seams.

The symptoms are recognizable to anyone who's worked on a SwiftUI codebase past its first few months. Nested NavigationLinks fight each other at runtime. @State variables tracking whether some sheet is presented get scattered across a dozen unrelated views. Bindings get threaded down four layers of view hierarchy just so a button five screens deep can pop back to root. Somewhere in there, a UserDetailView ends up knowing exactly which view to instantiate next and what data to hand it, which means it's making decisions that have nothing to do with what a UserDetailView is supposed to do: show a user's details.

The root cause is structural, not a matter of sloppy code. Views are being asked to do two jobs at once: render UI, and decide what happens next. SwiftUI's declarative model works against a single router managing an entire app's navigation, and any team that tries to force that shape eventually hits it. Navigation deserves to be treated as a real design problem from the start, not a wiring exercise you clean up once the deadline passes.

What NavigationStack actually changed in iOS 16 and why NavigationView couldn't get there

NavigationView had a structural ceiling: it gave you no programmatic, state-owned path. Navigation state lived somewhere inside the framework's internals, not in code you could read, mutate, or test. You could bind a boolean to isActive, sure, but you couldn't ask the framework "where is the user right now" and get a real answer back.

iOS 16 replaced it with NavigationStack, a stack-based container that behaves, conceptually, like UINavigationController but built the declarative way SwiftUI expects. Alongside it came NavigationPath, a type-erased collection that can hold a mix of destination types on one stack. NavigationLink(value:) replaced the deprecated isActive: and tag:selection: variants: a link now pushes a value onto the path rather than instantiating a view directly. And A destination modifier maps a given value type to the view that should render it, cleanly separated from whatever triggered the push in the first place.

What that unlocks in practice: the path array is mutable state you control directly, so jumping to a destination multiple screens deep is a matter of setting the array to reflect that state. NavigationView offered no comparable programmatic control over the stack. NavigationView had no equivalent mechanism for this kind of direct stack manipulation.

The path itself can be typed, a plain array of one Hashable type, when a flow only deals with one kind of destination. Or it can be a full NavigationPath when the flow mixes several domain types on one stack. Either way, Apple deprecated NavigationView outright when iOS 16 shipped. That's not a stylistic nudge, it's the direction the platform has committed to.

Out of this API, a three-layer model falls out almost naturally. The path array is the Navigation Model, the state layer, the authoritative record of where the user actually is. NavigationStack wired to that path, plus its navigationDestination handlers, is the Navigation View, the presentation layer. And the destination views themselves, the Feature Layer, stay standalone and self-contained: they receive data as input and render it. They do not navigate.

The Coordinator pattern's job and why it translates naturally onto NavigationStack

The Coordinator pattern predates SwiftUI by years. Soroush Khanlou described it during the UIKit era, in a piece called "Coordinators Redux," as a fix for Massive View Controller: view controllers that had absorbed UI code, data logic, and navigation decisions all in one place, until nobody could safely touch any of it.

The pattern splits those jobs cleanly. A view renders UI and reports what the user did. A coordinator decides what to show next and how, whether that's a push, a sheet, or a full-screen cover. A view model or model owns the data and business rules. None of the three steps on the others' responsibilities.

In UIKit, this was workable but clunky. You had to hand-roll the navigation stack yourself, hold onto a UINavigationController reference, and push or pop screens imperatively, hoping you tracked the stack's real state correctly the whole time.

NavigationStack removes that friction almost entirely. The path array the coordinator owns is the navigation state. Mutating it isn't a step toward navigating, it's the navigation act itself. No UIViewController reference to keep alive, no imperative push call that might silently disagree with what's actually on screen.

The Coordinator now has an obvious home: an ObservableObject (or, as of Swift 6, an @Observable class) that holds the path, exposes methods like push and pop, and gets handed to views through the environment. Views call those methods. They never build a destination view themselves. The decoupling isn't a matter of discipline anymore, it's built into the structure.

Building the core of a typed Coordinator: Route enum, path ownership, and the four navigation primitives

Everything starts with a Route enum. It needs to conform to Hashable, since NavigationPath requires that, and to Identifiable wherever sheets or full-screen covers are in play. Each case stands for one destination, carrying whatever data that destination needs as an associated value: .userDetail(User), .settings, .editProfile. That pattern shows up consistently in write-ups of the approach across the SwiftUI community.

One rule matters more than the others here: the Route enum should avoid importing SwiftUI or building views inside itself. That's a recognized pitfall in the SwiftUI community: the moment the enum starts constructing views, it picks up a hard dependency on the UI framework, and the whole routing layer stops being testable on its own. View construction belongs in the navigationDestination(for:) handler, not baked into the route.

The Coordinator class itself owns @Published var path: [Route] (or NavigationPath, for flows mixing types). It should also own separate published properties for sheets and full-screen covers, @Published var sheet: Sheet? and @Published var fullScreenCover: FullScreenCover?, since those are two distinct presentation styles with two distinct jobs, a pattern described in coverage of the coordinator approach on Medium. Four methods do the actual work: push(_:), pop(), popToRoot(), and replacePath(_:). One guard is non-negotiable: pop() has to check the path isn't already empty before calling removeLast(), since calling it on an empty array crashes at runtime, a failure mode documented on daily.dev.

Wiring it up is simple once the pieces exist. The coordinator gets instantiated as @StateObject at the root and handed down through .environmentObject, so no child view ever needs to build one itself. NavigationStack(path: $coordinator.path) binds the stack directly to coordinator-owned state. A single .navigationDestination(for: Route.self) block switches over every case, one place, one switch statement, no destination logic scattered across the app.

Deep links slot into the same system rather than needing a parallel one. Give Route a failable initializer, init?(url:), that parses URLComponents and maps the result onto the same enum cases a button tap would produce. A universal link and a NavigationLink end up running through identical routing logic, a design that follows naturally from the same enum-based routing structure.

Where naive single-coordinator implementations break and what the failures reveal

Four problems show up reliably in a basic, single-coordinator setup, documented on daily.dev. The root view tends to do too much: it owns the coordinator, hosts the NavigationStack, and declares every destination mapping, three jobs that a dedicated Router view could split apart instead. The Route enum sometimes imports SwiftUI to build views inline, which quietly breaks the routing layer's testability. Sheets and full-screen covers often go unmodeled entirely, because a coordinator that only tracks the push stack is only handling part of the job. And the empty-stack crash from calling removeLast() on nothing is a runtime failure, not something the compiler will ever catch for you.

Underneath all four is one structural problem: a single coordinator ends up owning every route in the app. Onboarding, auth, the home tab, settings, checkout, all crammed into one Route enum and one path array.

At any real scale, that setup buckles. The Route enum becomes a dependency that cuts across the whole codebase, so changing one feature's routes risks quietly breaking another's. The coordinator itself becomes nearly impossible to test in isolation, because every flow in the app is now entangled with every other flow inside one object. And deep links or programmatic jumps that need to cross feature boundaries force the coordinator to know about all of them simultaneously, defeating the point of separating concerns in the first place.

SwiftUI was never designed for a single router to carry an entire app's navigation. A monolithic coordinator is exactly the shape that principle warns against, and it's worth taking seriously before the codebase grows large enough to make the fix expensive.

Composable coordinators: each flow owns its path, parent coordinators orchestrate between flows

The fix is to scope coordinators to flows, not to the app as a whole. Onboarding gets its own coordinator and its own Route enum. Each tab, Home, Profile, Settings, gets one too. Any wizard-style flow, checkout being the obvious example, gets its own as well.

Each flow coordinator owns its own @Published path rather than sharing one global array. Its Route enum only ever describes destinations that belong to that flow. It's the only object that knows how to build views for its own routes, and it can be instantiated and tested entirely on its own, with no other part of the app in the picture.

Above all of them sits a parent, an AppCoordinator, though it doesn't own a navigation path at all. Its job is to track a root state, splash, login, onboarding, main, and switch between child coordinators based on that state. jorgemrht.dev, writing in September 2025, describes this as the app coordinator acting like a stage director, switching scenes based on coordinator.root rather than managing a stack of its own.

Keeping parallel flows independent matters just as much as splitting them apart in the first place. A key principle of composable coordinators is that tab A's coordinator mutating its own path should have zero effect on tab B's. Each child coordinator gets injected into its own subtree through .environment or .environmentObject, so views only ever see the coordinator for their own flow, never the app-level one.

One antipattern worth naming directly: don't nest a NavigationStack inside another NavigationStack. Each flow coordinator should connect to exactly one stack, a rule practitioners flag explicitly, since nested stacks produce navigation behavior that's genuinely hard to reason about.

Because routes are just enum cases, plain value types, the same route can get pushed from a deep link handler, a button tap, or a unit test, and the coordinator method is the single callsite no matter which one triggered it.

Updating the Coordinator for Swift 6 and the @Observable macro

The @Observable macro, part of the Observation framework, replaces ObservableObject and @Published for coordinator state, and it changes the coordinator's shape in a few concrete ways. There's less boilerplate: no @Published annotation needed on every property, since the macro instruments property access on its own. SwiftUI also re-renders more precisely: a view only redraws when a property it actually reads has changed, not every time anything on the coordinator changes. Coordinators built with @Observable get injected via .environment(coordinator) directly, skipping the .environmentObject and @EnvironmentObject pairing altogether.

Swift 6's strict concurrency checking closes off a separate category of bug: the compiler now catches data races at compile time, through Sendable conformances and actor isolation, that used to surface only at runtime, and usually only intermittently. Two async operations racing to mutate the same path array used to be the kind of bug that showed up once in ten runs and vanished when you tried to reproduce it. Under Swift 6, a path mutation happening off the main actor without being dispatched back becomes a compile error instead of a silent race.

The @Entry macro, available from Xcode 16 and Swift 6 onward, cuts down the ceremony around exposing a coordinator through the environment. Before @Entry, injecting a scoped coordinator meant writing a custom EnvironmentKey conformance and a computed property extension on EnvironmentValues, two separate pieces of boilerplate for something conceptually simple. @Entry collapses that down to one line.

For a new project starting today, a widely shared recommendation for new projects is MVVM plus Coordinators plus dependency injection, written in Swift 6, targeting iOS 17 and up, where @Observable handles most of the reactive plumbing without extra code.

Testing composable coordinators and the boundary between navigation and view logic

Navigation logic has historically been some of the least tested code in a SwiftUI app, for a straightforward reason: when views own navigation decisions, testing them means instantiating views, and instantiating views means standing up a rendering context. A unit test quietly turns into an integration test, and most teams don't bother.

Coordinator ownership removes that obstacle entirely. A coordinator is a plain Swift class, nothing more, so it can be instantiated and exercised inside XCTest with no SwiftUI view anywhere near it. Testing push() means calling coordinator.push(.userDetail(mockUser)) and asserting the path now contains that route. Testing pop() means asserting the path shrank by one, and separately confirming the guard against popping an empty path actually holds. Testing popToRoot() means asserting the path is empty afterward. Testing deep link parsing means calling Route(url: mockURL) and checking that the resulting case, and its associated value, match what the URL was supposed to produce.

Each flow coordinator's Route enum is a closed, finite set of cases, and the compiler enforces exhaustive switching over it. Add a new destination and forget to handle it in navigationDestination(for:), and that's a compile error, not something that slips through and only shows up as a blank screen in production.

The same separation that makes coordinators testable also draws a clean line around what a navigation test should never cover. View rendering, layout, and interaction handling stay in UI tests and previews, where they belong. A coordinator test's only job is confirming the right route got pushed, nothing about how that route's view happens to look.

Destination views stay standalone and independently previewable, since they take data as input and call coordinator methods as output, nothing more entangled than that. That boundary is clean enough that a mock coordinator can stand in for the real one in view-level tests without any loss of fidelity.

At team scale, that separation pays for itself. When each feature's coordinator is scoped to its own module, one engineer working on checkout can't accidentally break someone else's onboarding flow by editing a shared Route enum, because there isn't one. A coordinator that's independently testable turns out to be, almost as a side effect, a coordinator that's independently ownable, and for a team shipping features in parallel, that's most of the point.

Sources

  1. The Coordinator Pattern for SwiftUI (NavigationStack) | daily.dev
  2. Coordinator Pattern in SwiftUI: Best-Practice Navigation - Jorgemrht • Swift • IA
  3. SwiftUI Flow Coordinator pattern with NavigationStack to coordinate navigation between views (iOS 16 +) | by Michał Ziobro | Mac O’Clock | Medium
  4. Modern SwiftUI Navigation: Best Practices for 2025 Apps | Medium

More in Swift Architecture