Swift and SwiftUI tutorials for Swift Developers

What is and how to use NavigationStack in SwiftUI

If you have been developing SwiftUI applications since its inception, you know the pain. For years, navigation was the framework’s Achilles’ heel. NavigationView was rigid, programmatic navigation relied on hacks involving isActive or tag, and “pop to root” was an odyssey of nested bindings.

With the arrival of iOS 16, Apple introduced a radical paradigm shift: NavigationStack.

This component isn’t just a name change; it’s a total reengineering of how SwiftUI understands screen flow. NavigationStack transforms navigation from a static visual hierarchy into a dynamic data structure.

In this massive tutorial, we will break down every atom of NavigationStack. You will learn how to decouple your UI from your navigation logic, implement “Coordinator” style architectures, handle Deep Links, and persist user state.


1. The Paradigm Shift: From Views to Data

To master NavigationStack, you must first forget NavigationView.

In the old model, navigation was defined by the location of the link. If you wanted to go from View A to View B, you had to place a NavigationLink inside View A that physically contained View B. This tightly coupled the screens.

NavigationStack introduces value-based navigation. The premise is simple: The navigation stack is just an Array.

  • If the array is empty, you are at the root view.
  • If you add an element to the array, you “Push” a new screen.
  • If you remove the last element, you “Pop”.
  • If you empty the array, you return to the start.

Key Concepts

  1. The Container (NavigationStack): The frame that manages the display.
  2. The Path (Path): The collection of data representing where you are.
  3. The Destination (navigationDestination): The rule that says “when you see this data type, show this view”.

2. Basic Implementation: Declarative Navigation

Let’s start with the simplest usage, ideal for lists and linear flows where you don’t need complex programmatic control.

The fundamental change here is the .navigationDestination(for:) modifier.

struct User: Identifiable, Hashable {
    let id = UUID()
    let username: String
}

struct BasicStackView: View {
    let users = [
        User(username: "Ana"),
        User(username: "Beto"),
        User(username: "Carla")
    ]

    var body: some View {
        NavigationStack {
            List(users) { user in
                // 1. The link now carries DATA, not Views.
                NavigationLink(value: user) {
                    Text(user.username)
                }
            }
            .navigationTitle("Users")
            // 2. We define the destination only once for this data type.
            .navigationDestination(for: User.self) { user in
                UserProfileView(user: user)
            }
        }
    }
}

struct UserProfileView: View {
    let user: User
    var body: some View {
        Text("Profile of \(user.username)")
            .font(.largeTitle)
    }
}

Why is this better?

  1. Decoupling: The list doesn’t know which view will be shown. It only knows it is sending a User object.
  2. Performance (Lazy Loading): In the old NavigationView, if you had a list of 1000 items with NavigationLink(destination:...), SwiftUI instantiated all 1000 destination views immediately. With NavigationLink(value:), the destination view is only created when the user taps the link.
  3. Cleanliness: You can have multiple NavigationLinks sending User objects from different parts of the hierarchy, and they will all be captured by the same .navigationDestination modifier.

Important Note: Any object you pass in value must conform to the Hashable protocol.


3. Programmatic Navigation: The Holy Grail

This is where NavigationStack shines. By controlling the path, we can manipulate navigation from business logic, without touching the UI.

Managing a Homogeneous Path

If your navigation only involves one data type (for example, navigating through page numbers or categories of the same type), you can use a simple Array as state.

struct ProgrammaticView: View {
    // This variable is the "Single Source of Truth" for your navigation
    @State private var path: [Int] = []

    var body: some View {
        NavigationStack(path: $path) {
            VStack(spacing: 20) {
                Button("Go to screen 1") {
                    path.append(1) // Manual navigation
                }
                
                Button("Jump directly to 5 then 8") {
                    path = [5, 8] // Instant deep linking
                }
            }
            .navigationTitle("Home")
            .navigationDestination(for: Int.self) { number in
                NumberDetailView(number: number, path: $path)
            }
        }
    }
}

struct NumberDetailView: View {
    let number: Int
    @Binding var path: [Int] // Pass the binding to manipulate the stack

    var body: some View {
        VStack {
            Text("Screen #\(number)")
            
            Button("Next (\(number + 1))") {
                path.append(number + 1)
            }
            
            Button("Back to Home (Pop to Root)") {
                path.removeAll() // The easiest way to return to root in iOS history
            }
            
            Button("Go back 2 steps") {
                if path.count >= 2 {
                    path.removeLast(2)
                }
            }
        }
    }
}

Capability Analysis

With this approach, you have total control:

  • Push: path.append(value)
  • Pop: path.removeLast()
  • Pop to Root: path.removeAll()
  • History Manipulation: You can insert intermediate views or replace the entire stack by assigning a new array.

4. NavigationPath: Complex and Heterogeneous Navigation

In a real application, you rarely navigate by just one data type. It is common to go from a list of Products -> ProductDetail -> UserProfile -> Settings.

An Array<Int> won’t work here. We need an array that can hold distinct types. For this, Apple created NavigationPath.

NavigationPath is a type-erased collection that can store any Hashable value.

struct Product: Hashable { let name: String }
struct User: Hashable { let name: String }
struct Settings: Hashable { let id: String }

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

    var body: some View {
        NavigationStack(path: $path) {
            List {
                Section("Store") {
                    NavigationLink("View iPhone", value: Product(name: "iPhone 15"))
                    NavigationLink("View Mac", value: Product(name: "MacBook Pro"))
                }
                
                Section("Social") {
                    NavigationLink("Admin Profile", value: User(name: "Admin"))
                }
                
                Section("System") {
                    Button("Go to Settings") {
                        path.append(Settings(id: "General"))
                    }
                }
            }
            // Define a destination for EACH possible type in the stack
            .navigationDestination(for: Product.self) { product in
                ProductView(product: product)
            }
            .navigationDestination(for: User.self) { user in
                UserView(user: user)
            }
            .navigationDestination(for: Settings.self) { settings in
                SettingsView(settings: settings)
            }
        }
    }
}

With NavigationPath, SwiftUI automatically knows which view to render based on the type of the element currently at the top of the stack.


5. Professional Architecture: The Router / Coordinator Pattern

So far we have used @State inside the view. But in a professional app (MVVM, Clean Architecture), navigation logic shouldn’t be in the View. It should be in an object responsible for the flow.

Let’s create a reactive Router that we can inject anywhere in the app.

Step 1: Create the Router

final class Router: ObservableObject {
    // Publish the path so the UI redraws on change
    @Published var path = NavigationPath()
    
    // Semantic methods to avoid exposing 'path' directly
    func navigate(to destination: any Hashable) {
        path.append(destination)
    }
    
    func navigateBack() {
        path.removeLast()
    }
    
    func navigateToRoot() {
        path = NavigationPath()
    }
}

Step 2: Inject into the Environment

struct AppRoot: View {
    @StateObject private var router = Router()
    
    var body: some View {
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: String.self) { text in
                    DetailView(text: text)
                }
                .navigationDestination(for: Int.self) { number in
                    NumberView(number: number)
                }
        }
        .environmentObject(router) // Available to all children
    }
}

Step 3: Use in Child Views

Now, any child view can navigate without knowing where it comes from or where it’s going, simply by asking for the Router.

struct DetailView: View {
    @EnvironmentObject var router: Router
    let text: String
    
    var body: some View {
        VStack {
            Text("Detail: \(text)")
            
            Button("Go to number 100") {
                // View-agnostic navigation
                router.navigate(to: 100)
            }
            
            Button("Back to Home") {
                router.navigateToRoot()
            }
        }
    }
}

This pattern solves the problem of passing Bindings from parents to children through 5 levels of hierarchy.


6. State Persistence and Deep Linking

One of the most powerful features of treating navigation as data is that data can be saved.

Imagine this scenario: The user navigates deep into your app, minimizes the application, and the system closes the app to free up memory. When the user returns, they expect to be where they left off. With NavigationView this was nearly impossible. With NavigationStack and Codable, it’s trivial.

Path Serialization

Unfortunately, NavigationPath does not conform to Codable automatically because it contains erased types. For robust persistence, it is better to use an Array of an Enum that contains all your possible routes.

// 1. Define all possible screens
enum AppRoute: Codable, Hashable {
    case product(id: Int)
    case userProfile(userId: String)
    case settings
}

class PersistableRouter: ObservableObject {
    @Published var path: [AppRoute] = [] {
        didSet {
            saveState()
        }
    }
    
    private let saveKey = "navigation_history"
    
    init() {
        // Restore state on launch
        if let data = UserDefaults.standard.data(forKey: saveKey),
           let decoded = try? JSONDecoder().decode([AppRoute].self, from: data) {
            path = decoded
        }
    }
    
    private func saveState() {
        if let encoded = try? JSONEncoder().encode(path) {
            UserDefaults.standard.set(encoded, forKey: saveKey)
        }
    }
}

By using this PersistableRouter in your NavigationStack, the application will automatically remember the user’s navigation history between sessions.

Deep Linking (External URLs)

If your app receives a URL like myapp://product/45, handling it is as simple as parsing the URL, creating the corresponding enum (.product(id: 45)), and adding it to the path array. SwiftUI will instantly reconstruct the entire visual interface.

.onOpenURL { url in
    if let route = parseUrlToRoute(url) {
        // Reset to root or add to existing history
        router.path.append(route)
    }
}

7. Common Pitfalls and Best Practices

Although NavigationStack is superior, there are traps that are easy to fall into.

A. Do not nest NavigationStacks

Never put a NavigationStack inside another. This will cause double navigation bars and break the history. If you need to split the screen (like on iPad), use NavigationSplitView as the main container, not NavigationStack.

B. The .navigationDestination modifier

Place .navigationDestination modifiers as high as possible in the hierarchy (usually on the stack’s root view). If you place a destination modifier inside a view that disappears (for example, inside an if), navigation will stop working.

C. Beware of Class Objects

Objects you pass in the path must be Hashable. If you use classes (class), ensure you implement hash(into:) and == correctly based on a unique ID, not pointer identity, to avoid erratic behavior when reloading views. Ideally, use struct or enum.


Conclusion

NavigationStack is the missing piece in the SwiftUI puzzle. By transforming navigation into pure state management, it allows us to write robust, testable, and predictable applications.

Summary of Advantages:

  1. Typed Navigation: Use of value and navigationDestination to decouple UI.
  2. Centralized Control: Use of NavigationPath or Arrays to handle flow from ViewModels.
  3. Flexibility: Native deep linking and state restoration.
  4. Performance: Lazy loading of destination views by default.

If you are still using NavigationView, now is the time to migrate. Your future code will thank you.

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

How to learn the Swift Programming Language

Next Article

Best AI coding tools for iOS Developers using Swift and SwiftUI

Related Posts