SwiftUI Navigation the Easy Way
SwiftUI's navigation story has come a long way. We've moved from the quirky NavigationView to the much more capable NavigationStack, and with it, a whole new world of programmatic navigation opened up. But as your app grows from a few screens to dozens, navigation can quickly become a tangled mess of scattered NavigationLink destinations, inconsistent patterns, and that nagging feeling that there must be a better way.
The good news? There is. And it doesn't require adopting a heavyweight architecture or pulling in third-party dependencies. It's just Swift's type system doing what it does best.
Why Not TCA or Coordinators?
Before we dive in, let's address two popular approaches you might be considering.
The Composable Architecture (TCA)
TCA is a powerful framework, but using it primarily for navigation is like buying a Swiss Army knife when you need scissors. The learning curve is steep - you need to understand reducers, stores, and state scoping before you can push a view onto a stack.
More concerning are the performance implications. TCA's design means every view in your hierarchy receives the entire application state. This directly conflicts with how SwiftUI efficiently diffs and redraws views. There are also well-documented issues with NavigationStack integration - store recreation problems, animation bugs with programmatic dismissal, and the general friction of fighting against SwiftUI's declarative model.
TCA is excellent for what it's designed for. But if you're adopting an entire architecture just to manage navigation, you're probably overcomplicating things.
The Coordinator Pattern
Coordinators were a revelation in UIKit. Soroush Khanlou's 2015 proposal gave us a clean way to extract navigation logic from view controllers. But SwiftUI is a fundamentally different paradigm.
The translation isn't clean. SwiftUI's view-centric nature means coordinators become tightly coupled to views anyway - you end up adding rootView and destination properties that defeat the abstraction's purpose. And since NavigationStack only handles push/pop (not sheets or full-screen covers), you end up building custom wrappers that add complexity without adding clarity.
A Lighter Path
What if we could get type-safe navigation, centralized routing, and clean architecture using just Swift and SwiftUI? That's the pattern I've been using in production apps, and it scales remarkably well.
The Screen Enum - Your Navigation Contract
The foundation of this approach is a single enum that defines every possible destination in your app. Think of it as a contract: if a screen exists in your app, it has a case in this enum.
Now, a Screen enum isn't a new concept. Many developers have suggested this pattern before, and you've probably seen variations of it in blog posts and conference talks. But stick with me - the real power comes from how we wire everything together.
public enum Screen: Hashable, Identifiable {
case landmarks(LandmarksScreen)
case favorites(FavoritesScreen)
public var id: Self { self }
public enum LandmarksScreen: Hashable {
case root
case detail(Landmark)
case category(Category)
}
public enum FavoritesScreen: Hashable {
case root
case detail(Landmark)
}
}
A few things to notice here:
Nested enums for hierarchy. Each tab or feature area gets its own nested enum. This keeps the top-level Screen manageable while allowing each feature to define its own navigation graph.
Associated values for data. When you navigate to a detail screen, you pass what that screen needs - sometimes a full domain model, sometimes just an ID or a title. It depends on the destination. If the detail view already has access to a data source and just needs an identifier to look up the record, pass the ID. If it needs the full object upfront, pass the model. The compiler ensures you provide exactly what the destination requires, no more, no less.
Hashable and Identifiable. These conformances are required for NavigationStack's path-based navigation. Since enums with Hashable associated values are automatically Hashable, this usually just works.
The beauty of this approach is that the compiler now enforces valid navigation. Try to push a screen that doesn't exist? Compile error. Forget to pass required data? Compile error. Refactor a screen's requirements? The compiler shows you every call site that needs updating.
DestinationContent - The View Factory
With all our destinations defined, we need a way to map them to actual views. Enter DestinationContent - a single, centralized factory that takes a Screen and returns the corresponding view.
public struct DestinationContent {
@ViewBuilder
public static func content(
for screen: Screen,
path: Binding<[Screen]>? = nil
) -> some View {
switch screen {
case let .landmarks(screen):
LandmarksDestination(screen: screen, path: path)
case let .favorites(screen):
FavoritesDestination(screen: screen, path: path)
}
}
}
Notice the path parameter - we'll come back to why that's important shortly.
For each feature area, we create a dedicated destination view that handles the nested routing:
struct LandmarksDestination: View {
let screen: Screen.LandmarksScreen
var path: Binding<[Screen]>?
var body: some View {
switch screen {
case .root:
LandmarkListView()
case let .detail(landmark):
LandmarkDetailView(landmark: landmark)
case let .category(category):
CategoryView(category: category, path: path)
}
}
}
This modular approach keeps each feature's navigation logic contained. When your landmarks feature grows to handle filters, search results, and user reviews, all that complexity stays in LandmarksDestination. The top-level DestinationContent remains clean.
Why centralize instead of scattering .navigationDestination modifiers throughout your views? A few reasons:
- Single source of truth. Every screen-to-view mapping lives in one place.
- Easier debugging. Navigation issues? Check the destination content.
- Consistent patterns. New team members learn one approach, not twelve variations.
The Pop-to-Root Problem
Here's a scenario that trips up many navigation implementations: a confirmation screen that needs to dismiss the entire navigation stack and return to root.
Think of a checkout flow: Browse → Product Detail → Cart → Payment → Confirmation. When the user taps "Done" on the confirmation screen, they should land back at the browse screen, not pop back through every intermediate step.
The naive solution is to inject some global navigator that the confirmation screen calls into. But this creates tight coupling - suddenly your ConfirmationView knows about your app's navigation infrastructure. Want to show that same confirmation view in a different context? Too bad, it's married to that navigator.
The solution is simpler: pass the path binding only to screens that need it.
// In your destination content
case let .confirmation(landmark):
ConfirmationView(
landmark: landmark,
path: path // Only injected where needed
)
struct ConfirmationView: View {
let landmark: Landmark
@Binding var path: [Screen]
var body: some View {
VStack {
// ... confirmation UI
Button("Done") {
path = [] // Pop to root
}
}
}
}
The view doesn't know or care where it came from - it just knows that someone gave it a path binding, and it can manipulate that path to control navigation.
This is the key selling point of this entire pattern: views can be shown from anywhere. Your LandmarkDetailView works identically whether the user navigated from search, from favorites, from a deep link, or from a push notification. Same code, no special handling, no context awareness required.
Wiring It Up
Let's put it all together. Each tab maintains its own navigation path as @State, and we wire up the destination content with a single .navigationDestination modifier.
struct ContentView: View {
@State private var selectedTab: Tab = .landmarks
@State private var landmarksPath: [Screen] = []
@State private var favoritesPath: [Screen] = []
enum Tab {
case landmarks
case favorites
}
var body: some View {
TabView(selection: $selectedTab) {
NavigationStack(path: $landmarksPath) {
LandmarkListView()
.navigationDestination(for: Screen.self) { screen in
DestinationContent.content(
for: screen,
path: $landmarksPath
)
}
}
.tabItem { Label("Landmarks", systemImage: "map") }
.tag(Tab.landmarks)
NavigationStack(path: $favoritesPath) {
FavoritesView()
.navigationDestination(for: Screen.self) { screen in
DestinationContent.content(
for: screen,
path: $favoritesPath
)
}
}
.tabItem { Label("Favorites", systemImage: "heart") }
.tag(Tab.favorites)
}
}
}
Within any view, navigation is beautifully simple - just use NavigationLink with a value:
struct LandmarkListView: View {
let landmarks: [Landmark]
var body: some View {
List(landmarks) { landmark in
NavigationLink(value: Screen.landmarks(.detail(landmark))) {
LandmarkRow(landmark: landmark)
}
}
.navigationTitle("Landmarks")
}
}
Notice that LandmarkListView doesn't need to know anything about navigation stacks, paths, or destinations. It just declares "tapping this row should navigate to this screen" using a type-safe value. SwiftUI and our destination content handle the rest.
This is why NavigationLink with values is the preferred approach - your views don't need context about where they sit in the navigation hierarchy. They simply declare intent, and the infrastructure makes it happen.
Handling Deep Links
Sometimes you need to navigate programmatically - responding to a push notification, handling a URL scheme, or restoring state. For these cases, you can elevate your path state into an observable navigator:
@Observable
final class Navigator {
var selectedTab: Tab = .landmarks
var landmarksPath: [Screen] = []
var favoritesPath: [Screen] = []
func handleDeepLink(_ url: URL) {
guard let screens = parseURL(url) else { return }
// Select the appropriate tab
if let first = screens.first {
switch first {
case .landmarks: selectedTab = .landmarks
case .favorites: selectedTab = .favorites
}
}
// Set the path
switch selectedTab {
case .landmarks:
landmarksPath = screens
case .favorites:
favoritesPath = screens
}
}
private func parseURL(_ url: URL) -> [Screen]? {
// Pattern match URL components to Screen cases
// e.g., "/landmarks/123" -> [.landmarks(.detail(landmark))]
}
}
The key insight is that deep link handling is just "figure out which screens this URL represents, then set the path." Your Screen enum already defines every valid destination, so URL parsing becomes straightforward pattern matching.
For most apps, this is all you need. The navigator holds state, deep links manipulate that state, and SwiftUI's reactive system handles the rest.
Cleaning It Up with View Modifiers
At this point, you might be looking at the repeated .navigationDestination(for: Screen.self) boilerplate and thinking there's got to be a cleaner way. There is.
A simple view modifier wraps the destination logic:
extension View {
func screenDestination(path: Binding<[Screen]>) -> some View {
self.navigationDestination(for: Screen.self) { screen in
DestinationContent.content(for: screen, path: path)
}
}
}
Now your tab setup becomes much cleaner:
NavigationStack(path: $landmarksPath) {
LandmarkListView()
.screenDestination(path: $landmarksPath)
}
We can do the same for NavigationLink. Instead of writing NavigationLink(value: Screen.landmarks(.detail(landmark))) every time, extend NavigationLink to accept a Screen directly:
extension NavigationLink where Destination == Never {
init(screen: Screen, @ViewBuilder label: () -> Label) {
self.init(value: screen, label: label)
}
}
Now navigation links read naturally:
NavigationLink(screen: .landmarks(.detail(landmark))) {
LandmarkRow(landmark: landmark)
}
These are small conveniences, but they add up. Your views stay focused on what they display, and the navigation mechanics fade into the background where they belong.
Wrapping Up
This pattern has served me well across multiple production apps, scaling from simple utilities to complex multi-tab applications with deep linking, state restoration, and intricate navigation flows. Here's why it works:
Type safety without ceremony. The Screen enum catches navigation errors at compile time, but you're not writing boilerplate or fighting the framework.
Views stay reusable. Because views declare navigation intent through values rather than knowing about infrastructure, they work in any context.
No external dependencies. This is just Swift enums and SwiftUI's built-in navigation. No package updates to worry about, no API changes to track, no framework-specific patterns to learn.
Scales gracefully. Start with a single enum and grow to nested feature destinations as your app expands. The pattern stays the same.
Is this the only way to do SwiftUI navigation? Of course not. But if you're looking for an approach that's lightweight, type-safe, and works with SwiftUI rather than against it, give this a try. It might just be the easy way you've been looking for.