SwiftQA Random

How do you manage navigation state in SwiftUI?

The foundational shift: navigation as state, not imperative pushes

UIKit’s UINavigationController.pushViewController is imperative. SwiftUI’s NavigationStack is declarative — navigation is a function of state: you hold a value representing “what’s currently on the stack,” and SwiftUI renders whatever that state describes.

1. NavigationStack + NavigationPath — the core mechanism

@Observable
final class AppRouter {
    var path = NavigationPath()
    func showProfile(userID: String) { path.append(Route.profile(userID)) }
    func popToRoot() { path.removeLast(path.count) }
}

enum Route: Hashable {
    case profile(String)
    case settings
    case checkout(CartID)
}

struct ContentView: View {
    @State private var router = AppRouter()
    var body: some View {
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .profile(let id): ProfileView(userID: id)
                    case .settings: SettingsView()
                    case .checkout(let cartID): CheckoutView(cartID: cartID)
                    }
                }
        }
    }
}

NavigationPath is a type-erased, Hashable-based stack. Pushing is path.append; popping is path.removeLast(n).

2. Where this state should live — the architectural decision

  • A dedicated Router/Coordinator-equivalent object — owns the path, exposes methods like showProfile(userID:), keeps navigation logic centralized and testable.
  • Scoped per feature vs. one global router — mirrors feature-module boundaries: a CheckoutRouter, a ProfileRouter, rather than one router every feature reaches into.
  • Injected via @Environment, not passed explicitly through every view:
struct DeepChildView: View {
    @Environment(AppRouter.self) private var router
    var body: some View {
        Button("Go to profile") { router.showProfile(userID: "123") }
    }
}

3. Deep linking — the payoff for centralizing navigation as state

func handleDeepLink(_ url: URL) {
    guard let route = Route(url: url) else { return }
    router.path = NavigationPath()
    router.path.append(Route.home)
    router.path.append(route)
}

Constructing the desired end-state directly is a nicer story than simulating a sequence of pushes.

4. Sheets, full-screen covers, and alerts — separate state, same principle

.sheet(item: $router.presentedSheet) { sheetRoute in
    switch sheetRoute {
    case .imagePicker: ImagePickerView()
    case .filterOptions: FilterOptionsView()
    }
}

Using an Optional bound value rather than a plain Bool keeps “what’s presented” and “what data it needs” as one piece of state.

5. Programmatic navigation testing

Because the router’s state is just a plain value/object, you can unit test navigation logic without rendering a view. Worth flagging: NavigationPath is intentionally type-erased and doesn’t let you inspect its contents directly — many teams use a plain [Route] array instead, trading NavigationPath’s heterogeneous-type flexibility for straightforward testability.

6. Multi-column / multi-stack apps — NavigationSplitView

For iPad/Mac-class layouts with sidebar/content/detail columns, NavigationSplitView manages multiple simultaneous navigation contexts, needing its own state modeling (typically a selected sidebar item + selected content item), distinct from single-stack NavigationStack state.

7. State restoration

Because routes are Hashable/Codable if designed that way, you get a natural path to state restoration — persisting the current path to disk and restoring it on next launch.

Interview-ready summary

ConcernMechanism
Push/pop stackNavigationStack + NavigationPath (or a plain [Route] array)
Centralizing navigation logicA Router/Coordinator-equivalent object owning the path, exposing intent methods
Making it available deep in the tree@Environment/.environment(router), not threading through every initializer
Deep linkingConstruct the target path directly rather than simulating sequential pushes
Modals.sheet(item:)/.fullScreenCover(item:) bound to optional route state
TestabilityRouter logic testable as plain state, though NavigationPath itself isn’t introspectable
Multi-column layoutsNavigationSplitView with its own selection-state model
State restorationPersist/restore the route array directly, if routes are Codable

One-liner if pressed: “I’d model navigation as state, not imperative calls — a NavigationPath or plain [Route] array owned by a dedicated router object, exposed via @Environment so views can express navigation intent without knowing about destination views directly, mirroring the same ‘screens don’t know what’s next’ principle Coordinators gave UIKit. The main current tradeoff worth knowing: NavigationPath’s type erasure makes it hard to introspect for testing, so I’d reach for a plain typed array instead whenever tests need to assert exactly what’s on the stack.”