NavigationStack is not NavigationView: a practitioner's guide to data-driven navigation
SwiftUI's NavigationStack finally gave us programmatic navigation that works. After a year of production use across three apps, here's everything I wish I'd known from day one.
NavigationView was a compromise. It was the first iteration of a hard problem — declarative, data-driven navigation in a framework that didn’t yet have the primitives to do it properly. You could build multi-level stacks with it, but programmatic deep-linking and stack mutation were brittle, and behaviour diverged enough across platforms (especially iOS vs. macOS) to require defensive guards.
NavigationStack, introduced in iOS 16, is a rethink. After shipping three production apps with it and migrating two legacy NavigationView codebases, I have a thorough picture of where it excels and where you’ll hit its edges.
The Core Idea: A Path-Based Stack
The mental model shift from NavigationView to NavigationStack is significant. With NavigationView, you pushed views by setting a @State binding on NavigationLink. The relationship between your data model and your navigation state was implicit and difficult to serialize.
NavigationStack exposes the stack directly as navigation state. For a single destination type, that can be a typed array. For mixed destination types, use NavigationPath:
@State private var navigationPath = NavigationPath()
NavigationStack(path: $navigationPath) {
RootView()
.navigationDestination(for: Article.self) { article in
ArticleDetailView(article: article)
}
.navigationDestination(for: User.self) { user in
UserProfileView(user: user)
}
}
The navigationPath is your navigation state. It’s a stack of pushed values. You push onto it to navigate forward, pop from it to go back, and replace it entirely to deep-link to any screen in your hierarchy — all programmatically, all without touching the view hierarchy.
navigationDestination: The Key to Typed Routing
The navigationDestination(for:) modifier decouples the what from the where. You register handlers for types, not for specific view instances. Push an Article value onto the path and SwiftUI finds the closest navigationDestination(for: Article.self) in the hierarchy and renders it.
This has a subtle but important implication: you can push model objects directly onto the navigation path, and your routing logic is co-located and typed. In practice, destination values must conform to Hashable (and to Codable too if you want to persist/restore the path). There’s no stringly-typed URL routing, no switch statements over string identifiers, no force-casting. The compiler validates your destination types, even though missing or misplaced navigationDestination(for:) handlers are still a runtime concern.
// Pushing navigation is just appending to the path
Button("Read Article") {
navigationPath.append(article)
}
// Deep link to a specific screen
func openFromNotification(_ notification: PushNotification) {
switch notification.type {
case .newArticle(let id):
guard let article = fetchArticle(id: id) else { return }
navigationPath = NavigationPath([article])
case .userMention(let userId):
guard let user = fetchUser(id: userId) else { return }
navigationPath = NavigationPath([user])
}
}
That openFromNotification function demonstrates the real power here: deep linking is just constructing a NavigationPath. The path represents where the user is in the app, not how they got there.
Codable Navigation State
NavigationPath can be made Codable — but only if every type you push onto it is also Codable. This enables something genuinely useful: persisting and restoring navigation state across app launches.
struct ContentView: View {
@State private var navigationPath = NavigationPath()
private let store = NavigationStateStore()
var body: some View {
NavigationStack(path: $navigationPath) {
RootView()
.navigationDestination(for: Article.self) { article in
ArticleDetailView(article: article)
}
}
.onAppear {
if let restored = store.load() {
navigationPath = restored
}
}
.onChange(of: navigationPath) { _, newPath in
store.save(newPath)
}
}
}
class NavigationStateStore {
private let key = "nav_path"
func save(_ path: NavigationPath) {
guard let rep = path.codable,
let data = try? JSONEncoder().encode(rep) else { return }
UserDefaults.standard.set(data, forKey: key)
}
func load() -> NavigationPath? {
guard let data = UserDefaults.standard.data(forKey: key),
let rep = try? JSONDecoder().decode(
NavigationPath.CodableRepresentation.self, from: data
) else { return nil }
return NavigationPath(rep)
}
}
This pattern restores the user exactly where they left off, including multiple levels deep. On iOS, system-level state restoration does some of this automatically, but having it under your explicit control lets you be selective — you might want to restore position in a reading list but not in a settings flow.
The Ownership Problem
One thing that trips people up: NavigationPath should be owned at the right level of your hierarchy.
If you put the path in a deeply nested view, it can’t be reached by the views that need to mutate it (notifications, universal links, etc.). If you put it in a global singleton, you’ve introduced implicit global state mutation that makes flows hard to reason about.
My pattern: own navigation state at the scene level, in an @State property that is passed down via the environment.
@Observable
class AppNavigationState {
var feedPath = NavigationPath()
var profilePath = NavigationPath()
var sheetItem: SheetDestination?
func navigateToArticle(_ article: Article) {
feedPath = NavigationPath([article])
}
}
@main
struct MyApp: App {
@State private var navigationState = AppNavigationState()
var body: some Scene {
WindowGroup {
RootView()
.environment(navigationState)
}
}
}
Any view can reach into AppNavigationState to trigger navigation. The state is centralized, but the navigation logic is expressed as methods with clear intent, not raw array manipulation scattered across views.
Tabs + NavigationStack: The Real Complexity
Where things get genuinely tricky is combining TabView with per-tab NavigationStack. Each tab needs its own path — otherwise switching tabs pops the other tab’s navigation. And you need to handle the “tap the tab bar to scroll to top and pop to root” behaviour that iOS users expect.
struct MainTabView: View {
@State private var navigationState = AppNavigationState()
@State private var selectedTab: Tab = .feed
var body: some View {
TabView(selection: $selectedTab) {
NavigationStack(path: $navigationState.feedPath) {
FeedView()
.navigationDestination(for: Article.self) { ArticleDetailView(article: $0) }
}
.tabItem { Label("Feed", systemImage: "newspaper") }
.tag(Tab.feed)
NavigationStack(path: $navigationState.profilePath) {
ProfileView()
.navigationDestination(for: User.self) { UserProfileView(user: $0) }
}
.tabItem { Label("Profile", systemImage: "person") }
.tag(Tab.profile)
}
.onChange(of: selectedTab) { old, new in
// If tapping the same tab again (which won't fire onChange), we'd need a different approach
// This handles cross-tab navigation triggers
}
}
}
The “tap tab to pop to root” behaviour requires detecting a same-tab tap on the already-selected tab. TabView doesn’t provide a direct callback for this — and onChange only fires when the value changes, so checking old == new inside it will never be true.
One versioning note for 2024-era codebases: the two-parameter onChange closure (old, new) is an iOS 17+ API shape. If you’re targeting iOS 16, use the single-parameter onChange closure and keep same-tab detection in a custom Binding setter.
The solution is a custom Binding that intercepts the tab setter:
private var tabSelection: Binding<Tab> {
Binding(
get: { selectedTab },
set: { newTab in
if newTab == selectedTab {
// Same tab tapped again — pop to root
switch newTab {
case .feed: navigationState.feedPath = NavigationPath()
case .profile: navigationState.profilePath = NavigationPath()
}
}
selectedTab = newTab
}
)
}
// Replace the `selection: $selectedTab` binding with:
TabView(selection: tabSelection) { ... }
What to Know Before Migrating from NavigationView
If you’re migrating an existing NavigationView codebase:
- The old
NavigationLink(destination:isActive:label:)pattern is no longer the preferred approach. Replace it with appending/removing values from the path for data-driven flows, or usenavigationDestination(isPresented:)when a simple boolean presentation is the right fit. NavigationLink(destination:)still works inside aNavigationStack, but linking by value (NavigationLink(value:)) is the recommended pattern.- The two-column layout (
NavigationViewwith.navigationViewStyle(.columns)) is replaced byNavigationSplitView— a separate, sidebar-aware component. Don’t try to replicate it withNavigationStack. NavigationPathis type-erased. You can mix types on the same stack (push anArticle, then aUser, then aComment). Each type just needs a registerednavigationDestination.
The migration is worth it. NavigationStack’s explicit path model makes deep linking, state restoration, and programmatic navigation first-class concerns rather than retrofit hacks. For apps built on iOS 16+, I now default to it without hesitation.