NavigationStack is not NavigationView: a practitioner's guide to data-driven navigation
All Articles
· iOS SwiftUI Navigation Swift

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.

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:

  1. 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 use navigationDestination(isPresented:) when a simple boolean presentation is the right fit.
  2. NavigationLink(destination:) still works inside a NavigationStack, but linking by value (NavigationLink(value:)) is the recommended pattern.
  3. The two-column layout (NavigationView with .navigationViewStyle(.columns)) is replaced by NavigationSplitView — a separate, sidebar-aware component. Don’t try to replicate it with NavigationStack.
  4. NavigationPath is type-erased. You can mix types on the same stack (push an Article, then a User, then a Comment). Each type just needs a registered navigationDestination.

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.