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, aProfileRouter, 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
| Concern | Mechanism |
|---|---|
| Push/pop stack | NavigationStack + NavigationPath (or a plain [Route] array) |
| Centralizing navigation logic | A 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 linking | Construct the target path directly rather than simulating sequential pushes |
| Modals | .sheet(item:)/.fullScreenCover(item:) bound to optional route state |
| Testability | Router logic testable as plain state, though NavigationPath itself isn’t introspectable |
| Multi-column layouts | NavigationSplitView with its own selection-state model |
| State restoration | Persist/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.”