Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 021 / 100

SwiftUI navigation: Swift & iOS best practices explained

NavigationStack and type-safe paths.

Expona Admin7 min read

SwiftUI Navigation: Why Getting It Right Matters

Navigation is the backbone of every iOS app, yet many teams still struggle with tangled state management and fragile deeplink handling. SwiftUI navigation: Swift & iOS best practices explained covers a modern approach using NavigationStack and type-safe paths that has proven reliable across production e-commerce, SaaS, and travel apps. In this guide, we'll walk through the patterns that keep navigation logic clean, testable, and maintainable as your app scales.

NavigationStack replaced the older NavigationView in iOS 16+ and fundamentally changed how we think about screen transitions. Instead of scattered NavigationLink calls scattered throughout your view hierarchy, NavigationStack centralizes navigation state and makes it explicit.

The key insight: a NavigationStack manages a stack of screens, and you push or pop screens by binding to a path. This path is either an array of values or a path type that conforms to Hashable. Let's look at a practical example from a product listing screen:

swift
import SwiftUI

struct Product: Identifiable, Hashable {
    let id: UUID
    let name: String
    let price: Double
}

struct ProductListView: View {
    @State private var path: [Product] = []
    let products = [
        Product(id: UUID(), name: "Laptop", price: 999),
        Product(id: UUID(), name: "Mouse", price: 29)
    ]
    
    var body: some View {
        NavigationStack(path: $path) {
            List(products) { product in
                NavigationLink(value: product) {
                    Text(product.name)
                }
            }
            .navigationTitle("Products")
            .navigationDestination(for: Product.self) { product in
                ProductDetailView(product: product)
            }
        }
    }
}

Here's what makes this pattern work: the path array holds Product values. When you tap a NavigationLink with value: product, that product is appended to the path. The navigationDestination(for:destination:) modifier watches the path and displays the corresponding view when a Product is encountered.

This separation of concerns—navigation logic in one place, UI in another—makes your code easier to reason about and test.

Traditional NavigationLink used String tags, which were error-prone and impossible to type-check at compile time. Modern NavigationLink uses value: parameter with types, giving you compile-time safety and the ability to pass rich objects directly.

Consider a real scenario: a user navigates from a product list to a detail screen, then to a review screen. With value-based links, the compiler ensures you're only pushing types that match your NavigationStack path:

swift
enum NavigationDestination: Hashable {
    case productDetail(Product)
    case reviews(productId: UUID)
    case checkout(items: [Product])
}

struct MainShoppingView: View {
    @State private var path: [NavigationDestination] = []
    
    var body: some View {
        NavigationStack(path: $path) {
            VStack {
                NavigationLink(value: NavigationDestination.productDetail(sampleProduct)) {
                    Text("View Product")
                }
                NavigationLink(value: NavigationDestination.checkout(items: [sampleProduct])) {
                    Text("Checkout")
                }
            }
            .navigationDestination(for: NavigationDestination.self) { destination in
                switch destination {
                case .productDetail(let product):
                    ProductDetailView(product: product)
                case .reviews(let productId):
                    ReviewsView(productId: productId)
                case .checkout(let items):
                    CheckoutView(items: items)
                }
            }
        }
    }
}

This enum-based approach scales well. You define all possible navigation destinations in one place, and adding a new screen means extending the enum—the compiler will catch any switch cases you miss. This is especially valuable in larger teams where multiple developers modify navigation logic.

Programmatic Navigation — Controlling the Stack from Code

Sometimes you can't rely on user taps alone. You might need to push a screen after an API call completes, dismiss multiple screens after a purchase, or inject a screen into the stack based on a deeplink. That's where programmatic navigation shines.

Because path is a @State variable that you bind to the NavigationStack with $path, you can mutate it from anywhere in your view logic. This gives you full control:

swift
struct CheckoutFlowView: View {
    @State private var path: [CheckoutStep] = []
    @State private var isProcessing = false
    
    enum CheckoutStep: Hashable {
        case shipping
        case payment
        case confirmation(orderId: String)
    }
    
    var body: some View {
        NavigationStack(path: $path) {
            VStack(spacing: 16) {
                Text("Shopping Cart")
                
                Button("Proceed to Checkout") {
                    path.append(.shipping)
                }
                
                // After shipping is filled, programmatically advance
                if shouldAdvanceToPayment {
                    Button("Continue to Payment") {
                        path.append(.payment)
                    }
                }
            }
            .navigationDestination(for: CheckoutStep.self) { step in
                switch step {
                case .shipping:
                    ShippingFormView(onSubmit: {
                        path.append(.payment)
                    })
                case .payment:
                    PaymentFormView(onSubmit: { orderId in
                        // After payment succeeds, replace stack with confirmation
                        path = [.confirmation(orderId: orderId)]
                    })
                case .confirmation(let orderId):
                    ConfirmationView(orderId: orderId)
                }
            }
        }
    }
    
    var shouldAdvanceToPayment: Bool {
        path.contains(.shipping)
    }
}

The key patterns here: path.append() to push a screen, path.removeLast() to pop, and path = [] to reset the entire stack. In the payment callback, replacing the entire path with a single confirmation screen ensures users can't swipe back to re-enter payment details—a common UX pattern in e-commerce.

Deep linking is essential for push notifications, email campaigns, and app shortcuts. The modern approach: parse the incoming URL and build a navigation path from it.

Suppose a user taps a product link in an email. Your app receives a URL like myapp://product/12345. You should parse that, extract the product ID, and inject it into your navigation stack:

swift
struct AppRootView: View {
    @State private var path: [NavigationDestination] = []
    @Environment(\.scenePhase) var scenePhase
    
    enum NavigationDestination: Hashable {
        case product(id: UUID)
        case order(id: String)
    }
    
    var body: some View {
        NavigationStack(path: $path) {
            HomeView()
                .navigationDestination(for: NavigationDestination.self) { dest in
                    switch dest {
                    case .product(let id):
                        ProductDetailView(productId: id)
                    case .order(let id):
                        OrderDetailView(orderId: id)
                    }
                }
        }
        .onOpenURL { url in
            handleDeepLink(url)
        }
    }
    
    private func handleDeepLink(_ url: URL) {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
            return
        }
        
        // Parse myapp://product/12345
        let pathSegments = components.path.split(separator: "/").map(String.init)
        
        guard pathSegments.count >= 2 else { return }
        
        let destination = pathSegments[0]
        let identifier = pathSegments[1]
        
        switch destination {
        case "product":
            if let productId = UUID(uuidString: identifier) {
                path.append(.product(id: productId))
            }
        case "order":
            path.append(.order(id: identifier))
        default:
            break
        }
    }
}

Notice the onOpenURL modifier—this is where your app intercepts deep links from notifications and external sources. Parse the URL, validate the data, and safely append to your path. The NavigationStack automatically animates to the new screen.

For even more complex scenarios (multi-level deep links), build the entire path array before assigning it to @State. This ensures the correct navigation hierarchy displays instantly.

Common Mistakes

  • Forgetting to make navigation types Hashable: If your enum or struct doesn't conform to Hashable, NavigationStack can't track it. Make it explicit with enum NavigationDestination: Hashable.
  • Mutating path inside a loop or during rendering: State mutations during view computation cause unpredictable crashes. Always mutate path in callbacks like onTapGesture or button actions.
  • Using complex objects without IDs: If you push a Product without an id, the stack can't reliably maintain state. Always ensure your navigation types are simple, comparable values.
  • Ignoring edge cases in deep link parsing: Malformed URLs should degrade gracefully—never force-unwrap when parsing external data. Log the issue and show the home screen instead.
  • Building separate NavigationStack instances per screen: Each NavigationStack maintains its own path. For app-wide navigation, keep one NavigationStack at the root and pass path binding down if needed, or use a view model.

Key Takeaways

  • NavigationStack with value-based NavigationLink replaces older NavigationView patterns and is more testable and maintainable.
  • Bind a @State path array to your NavigationStack to centralize navigation logic and make it explicit and predictable.
  • Define navigation destinations as a single enum type to ensure type safety and prevent string-based navigation errors.
  • Programmatic navigation via path mutation handles complex flows like multi-step checkouts, post-API-call transitions, and conditional screen advances.
  • Deep link parsing should live in an onOpenURL handler that safely builds a path array and appends to the NavigationStack.
  • Always make navigation destination types Hashable and avoid mutations during view render cycles.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles