Swift and SwiftUI tutorials for Swift Developers

NavigationStack vs NavigationView in SwiftUI

If you have been developing applications with SwiftUI since its inception in 2019, you have surely experienced a love-hate relationship with navigation. For years, NavigationView was the standard tool: a visual container that, while functional for quick prototypes, became an architectural nightmare when scaling complex applications. Issues with programmatic navigation, hacks involving isActive, and the difficulty of managing deep application state were daily struggles.

With the release of iOS 16, Apple introduced NavigationStack. It wasn’t just a simple update; it was a change in philosophy. We moved from navigation based on view hierarchy to data-driven navigation.

In this massive tutorial, we are going to break down what NavigationStack is, why it renders NavigationView obsolete, and how you can use it to build robust, testable, and persistent navigation flows.


Part 1: The Problem with the Past (NavigationView)

To understand the revolution that NavigationStack represents, we must first dissect why NavigationView was problematic.

1. Strong Coupling

In NavigationView, navigation was physically defined within the view.

// The old style
NavigationLink(destination: DetailView()) {
    Text("Go to detail")
}

Here, the parent view has to know exactly what the child view (DetailView) is and how to instantiate it. This creates strong coupling that hinders reusability and testing.

2. The Performance Problem (Eager Loading)

One of the most common mistakes in NavigationView was performance in long lists. If you had a list of 1,000 items, and each had a NavigationLink(destination: BigView()), SwiftUI often instantiated all 1,000 BigViews immediately, even before the user tapped anything. Although Apple improved this over time, the default behavior remained “eager.”

3. The Nightmare of Programmatic Navigation

Did you want to return to the home screen from 5 levels deep (“Pop to Root”)? With NavigationView, you had to pass @Binding booleans (isActive) through every intermediate layer, or use complex global singletons. It was error-prone and resulted in “spaghetti code.”


Part 2: What is NavigationStack?

NavigationStack is a container that displays a root view over which additional views can be “stacked” (pushed). At first glance, it looks the same as its predecessor, but the magic happens under the hood: NavigationStack separates visualization from navigation logic.

The fundamental premise is: The navigation stack is simply an Array of data.

Key Differences: NavigationStack vs. NavigationView

FeatureNavigationView (Deprecated)NavigationStack (Modern)
ParadigmView-DrivenData-Driven
Destination DefinitionIn the link itself (destination:)In a separate modifier (navigationDestination)
Programmatic NavigationUsing isActive or tag/selectionManipulating a Path (Array)
PerformanceEager loading (sometimes)Lazy loading by default
Deep LinkingComplex and fragileNative and simple
StateHard to persistEasily serializable (Codable)

Part 3: Basic Implementation (Declarative Navigation)

The most immediate change when using NavigationStack is how we define where we are going. We no longer say “go to this view,” but “go to this data.”

The .navigationDestination Modifier

Imagine a contact list application.

struct Contact: Identifiable, Hashable {
    let id = UUID()
    let name: String
}

struct ContactsView: View {
    let contacts = [
        Contact(name: "Ana"),
        Contact(name: "Bruno"),
        Contact(name: "Clara")
    ]

    var body: some View {
        NavigationStack {
            List(contacts) { contact in
                // 1. The link carries DATA, not a view
                NavigationLink(value: contact) {
                    Text(contact.name)
                }
            }
            .navigationTitle("Contacts")
            // 2. The destination is defined globally for the data type
            .navigationDestination(for: Contact.self) { contact in
                ContactDetailView(contact: contact)
            }
        }
    }
}

Analysis:

  1. NavigationLink(value:): This initializer takes a value that must conform to the Hashable protocol. It does not instantiate the destination view.
  2. .navigationDestination(for:): This modifier captures any link that emits a Contact.self type. It acts as a view factory.

Advantage: If you have links to Contact in the toolbar, in the list, or in a floating button, they will all use the same destination definition. The code is cleaner and DRY (Don’t Repeat Yourself).


Part 4: Advanced Programmatic Navigation (The Power of Path)

This is where NavigationStack justifies its existence. We can control navigation by binding the stack to a state variable. This variable represents the traversed “path.”

Scenario A: Homogeneous Path (Same Data Type)

If your navigation is linear and always uses the same data type (for example, navigating through related user profiles), you can use a simple Array.

struct HomogeneousPathView: View {
    // The stack is just an array of integers
    @State private var path: [Int] = []

    var body: some View {
        NavigationStack(path: $path) {
            VStack {
                Button("Start at level 1") {
                    path.append(1) // Programmatic Push
                }
                
                Button("Jump directly to level 5") {
                    path = [1, 2, 3, 4, 5] // Instant Deep Link
                }
            }
            .navigationDestination(for: Int.self) { level in
                LevelView(level: level, path: $path)
            }
        }
    }
}

Scenario B: Heterogeneous Path (NavigationPath)

In the real world, you navigate from a Product to a User and then to Settings. An Array<Int> won’t work. For this, Apple created NavigationPath, a type-erased collection that accepts any Hashable.

struct Product: Hashable { let name: String }
struct User: Hashable { let username: String }

struct ShopView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            List {
                Section("Products") {
                    NavigationLink("iPhone", value: Product(name: "iPhone 15"))
                    NavigationLink("MacBook", value: Product(name: "MacBook Pro"))
                }
                Section("Account") {
                    NavigationLink("My Profile", value: User(username: "Admin"))
                }
            }
            // Multiple destinations to handle different types
            .navigationDestination(for: Product.self) { product in
                ProductDetailView(product: product, path: $path)
            }
            .navigationDestination(for: User.self) { user in
                UserDetailView(user: user, path: $path)
            }
        }
    }
}

Navigation Operations with path

By having access to the path object, you can perform complex operations trivially:

  1. Go back (Pop):
path.removeLast()

2. Return to start (Pop to Root):

path = NavigationPath() // Or path.removeAll() if it's an Array

3. Go back X steps:

path.removeLast(2)

Part 5: MVVM Architecture and the Router Pattern

One of the biggest benefits of NavigationStack is that it allows you to move navigation logic out of the View and into the ViewModel or a Coordinator/Router object. This is vital for maintaining clean architecture.

Creating a Reusable Router

final class Router: ObservableObject {
    @Published var path = NavigationPath()
    
    // Semantic methods
    func navigateToProduct(_ product: Product) {
        path.append(product)
    }
    
    func navigateToSettings() {
        path.append("Settings") // Using a String as a simple identifier
    }
    
    func popToRoot() {
        path = NavigationPath()
    }
}

Injecting the Router

struct AppRoot: View {
    @StateObject private var router = Router()
    
    var body: some View {
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: Product.self) { product in
                    ProductView(product: product)
                }
                // ... other destinations
        }
        .environmentObject(router) // Inject the router into the environment
    }
}

Now, any child view, no matter how deep, can access the router and navigate without knowing the view hierarchy:

struct DeepChildView: View {
    @EnvironmentObject var router: Router
    
    var body: some View {
        Button("Back to home") {
            router.popToRoot()
        }
    }
}

Part 6: Persistence and Deep Linking

Since navigation is now just “data,” we can save that data to disk (UserDefaults or database) and restore it when the app restarts. This allows the user to return exactly to where they were if the system closes the app.

However, NavigationPath does not automatically conform to Codable. To achieve robust persistence, the best technique is to use an Array of an Enum representing all possible routes in your app.

Strategy with Codable Enum

enum AppRoute: Codable, Hashable {
    case product(id: Int)
    case userProfile(userId: String)
    case settings
}

class PersistableRouter: ObservableObject {
    @Published var path: [AppRoute] = [] {
        didSet {
            save()
        }
    }
    
    private let key = "nav_history"
    
    init() {
        if let data = UserDefaults.standard.data(forKey: key),
           let decoded = try? JSONDecoder().decode([AppRoute].self, from: data) {
            path = decoded
        }
    }
    
    func save() {
        if let encoded = try? JSONEncoder().encode(path) {
            UserDefaults.standard.set(encoded, forKey: key)
        }
    }
}

By using [AppRoute] as your path in the NavigationStack, state restoration is automatic.

Handling Deep Links (URLs)

If your app receives a URL like myapp://product/42, handling it is as simple as parsing the URL, constructing the .product(id: 42) enum, and adding it to the path array. SwiftUI will instantly reconstruct the entire view stack.

.onOpenURL { url in
    if let route = parseURL(url) {
        // Reset and go to destination, or add to history
        router.path.append(route)
    }
}

Part 7: Best Practices and Common Pitfalls

To close this tutorial, let’s review some vital tips for working with NavigationStack in production.

1. Do Not Nest NavigationStacks

A common mistake when migrating from NavigationView is putting one stack inside another. This will cause double navigation bars and erratic behavior. There should only be one NavigationStack controlling a complete screen hierarchy. If you need split views (like on iPad), use NavigationSplitView as the parent.

2. Location of .navigationDestination

Place your .navigationDestination modifiers as high as possible in the hierarchy, ideally attached to the NavigationStack or the immediate root view. If you place a destination inside a view that disappears (for example, inside an if), navigation will break.

3. Hashable is Mandatory

Remember that anything you put in path or value must be Hashable. If you are using Core Data models or complex classes, consider creating lightweight structures (DTOs) or enums representing the navigation, instead of passing the full heavy object.

4. Progressive Migration

You don’t need to rewrite your entire app today. NavigationStack can coexist in a project that has legacy parts, but they don’t mix well. If you start a new feature or flow, use NavigationStack.


Conclusion

NavigationStack is the navigation API that SwiftUI deserved from day one. By transforming application flow into a manipulable data structure, Apple has eliminated the need for complex handmade coordinators and drastically simplified architectures like MVVM.

Whether you need a simple “push” from one view to another, or a complex deep linking system with state restoration, NavigationStack offers a native, efficient, and elegant solution. It’s time to leave NavigationView behind and embrace the data-driven future.

If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

Best AI Assistant for Xcode

Next Article

Best SwiftUI Architecture

Related Posts