DEV Community

Cover image for Stop Using NavigationView - Mastering NavigationStack in 2026
Ahtisham Hasan Khan
Ahtisham Hasan Khan

Posted on Originally published at iahtisham.com

Stop Using NavigationView - Mastering NavigationStack in 2026

If you are still wrapping your SwiftUI views in NavigationView in 2026, we need to talk.

Apple soft-deprecated NavigationView years ago. While it might still "work" for simple prototypes, it is a nightmare for scalable, real-world applications. If you want to handle push notifications, deep links, or complex onboarding flows, you must use NavigationStack.

In this guide, Iโ€™ll show you how to move from "Push-and-Pray" navigation to a robust, state-driven routing system.

The Problem with the Old Way

In the early days of SwiftUI, we used NavigationLink directly inside our lists. It looked innocent enough:

// The Old Way (Don't do this anymore)
NavigationView {
    List(0..<5) { i in
        // โŒ Problem: DetailView is initialized immediately for EVERY row!
        NavigationLink("Go to Detail \(i)", destination: DetailView(i: i))
    }
}
Enter fullscreen mode Exit fullscreen mode

Two things go wrong here. First, NavigationLink(destination:) builds every DetailView up front, even for rows nobody taps. Second, navigation lives inside the views. There is no single value that says "the user is on screen X", so you can't push a screen from a notification, restore it from a deep link, or pop back to the root from code.

The New Way: navigation as data

NavigationStack (iOS 16+) flips this around. You describe where a link goes with a value, and you describe how to show that value once, with navigationDestination(for:):

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List(0..<5) { i in
                // โœ… Only a value is created here. DetailView is built on tap.
                NavigationLink("Go to Detail \(i)", value: i)
            }
            .navigationDestination(for: Int.self) { i in
                DetailView(i: i)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The destination view is now built lazily, and the link only carries an Int.

Step 1: Model your routes

Real apps have more than one kind of screen. Put them in one Hashable enum so every place in the app can name a destination the same way:

enum Route: Hashable {
    case profile(userID: String)
    case settings
    case order(id: Int)
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Own the path in a router

Bind the stack to an array of routes. Whoever owns that array controls navigation:

import Observation

@Observable
final class Router {
    var path: [Route] = []

    func push(_ route: Route) { path.append(route) }
    func pop() { _ = path.popLast() }
    func popToRoot() { path.removeAll() }
}
Enter fullscreen mode Exit fullscreen mode

@Observable needs iOS 17. On iOS 16, make it an ObservableObject with @Published var path.

Step 3: Wire the stack to the router

struct RootView: View {
    @State private var router = Router()

    var body: some View {
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .profile(let userID): ProfileView(userID: userID)
                    case .settings: SettingsView()
                    case .order(let id): OrderView(id: id)
                    }
                }
        }
        .environment(router)
    }
}
Enter fullscreen mode Exit fullscreen mode

Any child view can now navigate without knowing about its parents:

struct HomeView: View {
    @Environment(Router.self) private var router

    var body: some View {
        Button("Open settings") { router.push(.settings) }
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Deep links and notifications for free

Because the whole stack is just [Route], opening a screen from outside the UI means setting an array. For a URL like myapp://order/42:

.onOpenURL { url in
    guard url.host == "order",
          let id = Int(url.lastPathComponent) else { return }
    router.path = [.order(id: id)]
}
Enter fullscreen mode Exit fullscreen mode

The same works when a push notification arrives: parse the payload into a Route and assign the path. To jump several screens deep, assign several routes, for example [.profile(userID: "me"), .settings].

Mixed value types: NavigationPath

If different features push unrelated types and you don't want one big enum, use NavigationPath, a type-erased path that accepts any Hashable value:

@State private var path = NavigationPath()

NavigationStack(path: $path) {
    FeedView()
        .navigationDestination(for: Post.self) { PostView(post: $0) }
        .navigationDestination(for: User.self) { UserView(user: $0) }
}
Enter fullscreen mode Exit fullscreen mode

An enum is easier to read and test, so I start there and only reach for NavigationPath when features need to stay independent.

Quick migration checklist

  1. Replace NavigationView with NavigationStack. For sidebar layouts on iPad and Mac, use NavigationSplitView.
  2. Change NavigationLink(destination:) to NavigationLink(value:).
  3. Add one navigationDestination(for:) per value type, high in the hierarchy rather than inside lists.
  4. Move the path into a router you can reach from anywhere.
  5. Route deep links and notifications by assigning the path.

If you're building an iOS app and want routing like this from day one, that's the kind of work I do.


Originally published at iahtisham.com.

I'm Ahtisham, an independent iOS developer building native Swift apps. More from me:

  • ๐ŸŒ Portfolio and client work: iahtisham.com
  • ๐Ÿ’ธ AppPriceKit: free App Store subscription prices by country
  • ๐Ÿงพ JustInvoice: invoice maker for iPhone
  • ๐Ÿ“„ ScanIt: document scanner and PDF for iPhone
  • ๐ŸŒ™ NUR: prayer times, offline Qur'an and adhkar, free with no ads
  • ๐Ÿข PrismCode Labs

Find me on X @iAhtishamDev ยท GitHub ยท LinkedIn ยท Upwork

Top comments (0)