Typed Routes Simplify Navigation State

NavigationStack treats history as a stack of typed, Hashable Route enum values—empty array at root, append to push (e.g., path.append(.details(id: 42))), popLast() to pop, removeAll() for root. Centralize mapping in root's .navigationDestination(for: Route.self) { switch route { case .details(let id): DetailsView(id: id); case .settings: SettingsView() } }. Use NavigationLink(value: Route.details(id: 42)) instead of destination: closures. This centralizes routes, enables compiler-checked refactors, and avoids scattered state. For basic apps (<5 screens), bind @State path: Route directly to NavigationStack(path: $path).

Router Enables Programmatic Control at Scale

For apps with multiple flows (auth, onboarding, tabs), extract path to @MainActor ObservableObject Router with @Published path: Route, methods push(:), pop(), popToRoot(), setPath(:). Inject via @StateObject in root, pass @ObservedObject to children: router.push(.details(id: 7)). Keeps views UI-focused, supports post-login pushes without nested conditionals. Beats pre-iOS 16 NavigationView/isActive fragility, preventing double-pushes or reasoning issues in complex apps.

Parse URLs to Route via DeepLinkParser (guard scheme == "myapp"; if host == "details", id = Int(pathComponents.first): return .details(id: id)). Apply with .onOpenURL { if let newPath = parse(url) { router.setPath(newPath) } }—sets stack directly, no intermediates. For TabView, use separate NavigationStack per tab with own path/router to isolate histories; share Route enum or specialize (HomeRoute, SettingsRoute). Avoids cross-tab stack leakage.

Pitfalls Fixed by This Model

Define destinations only at stack root, not children, to prevent duplication. Stick to NavigationLink(value:) + typed paths over mixed destination: for consistency. Ensure Route conforms to Hashable (hashable payloads only). Persist serialized path manually for app restarts if needed—doesn't auto-save. Scales to 20+ screens, deep links, programmatic nav without hacks.