- Jul 12
SwiftUI Navigation Patterns - Programmatic Navigation vs Coordinator vs TCA
- DevTechie
When building a SwiftUI application with multiple screens, navigation quickly becomes one of the most important architectural decisions you need to make.
SwiftUI provides powerful navigation building blocks such as NavigationStack, NavigationPath, and navigationDestination. However, the way you organize navigation logic around these tools can have a significant impact on your app’s maintainability, scalability, and testability.
A small application may work perfectly with navigation logic placed directly inside views. But as the number of screens, flows, and navigation requirements grows, that same approach can become difficult to manage.
In this article, we will explore three common approaches for handling navigation in SwiftUI:
Programmatic Navigation using
NavigationStackandNavigationPathThe Coordinator Pattern
The Composable Architecture (TCA) Navigation
For each approach, we will explore how it works, its advantages and disadvantages, and when it makes sense to use it.
To keep the examples simple, we will model navigation between different household areas such as a garden, garage, kitchen, and bathroom.
Why Compare These Three Approaches?
These three approaches represent different levels of architectural complexity and control.
Programmatic Navigation
Programmatic navigation is the foundation of modern SwiftUI navigation. It uses Apple's built-in navigation APIs and keeps navigation state directly inside SwiftUI state management.
You control the NavigationPath, and SwiftUI takes care of presenting the correct destination.
This approach is simple and works well for small applications.
Coordinator Pattern
As applications grow, navigation logic often starts spreading across multiple views.
The Coordinator pattern solves this problem by extracting navigation decisions from views and moving them into a dedicated object.
Views no longer decide where they should navigate. Instead, they communicate with a Coordinator that manages the navigation flow.
This creates better separation between:
UI presentation
Navigation decisions
Application flow
TCA Navigation
The Composable Architecture takes navigation one step further by treating navigation as application state.
Instead of navigation being controlled by views or coordinator objects, navigation becomes part of a predictable unidirectional data flow:
View → Action → Reducer → State → View
This makes navigation highly testable and completely driven by state.
Together, these approaches represent a progression:
Basic SwiftUI Navigation
↓
Coordinator-Based Architecture
↓
State-Driven Navigation with TCA
Each approach provides more structure, but also introduces additional complexity.
1. Programmatic Navigation (NavigationStack + NavigationPath)
What Is Programmatic Navigation?
Programmatic navigation is the simplest navigation approach available in modern SwiftUI.
Before iOS 16, developers commonly used NavigationLink, where destinations were declared directly inside views.
With NavigationStack, navigation becomes state-driven.
Instead of directly presenting screens, you append values to a navigation path. SwiftUI then uses navigationDestination to determine which view should be displayed.
Adding a value pushes a new screen:
path.append(Route.garden)
Removing a value pops a screen:
path.removeLast()
Example
enum Route: Hashable {
case garden
case garage
case kitchen
case bathroom
}
struct HomeView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
List {
Button("Garden") {
path.append(Route.garden)
}
Button("Garage") {
path.append(Route.garage)
}
Button("Kitchen") {
path.append(Route.kitchen)
}
Button("Bathroom") {
path.append(Route.bathroom)
}
}
.navigationTitle("Home")
.navigationDestination(for: Route.self) { route in
switch route {
case .garden:
GardenView()
case .garage:
GarageView()
case .kitchen:
KitchenView()
case .bathroom:
BathroomView()
}
}
}
}
}
How It Works
The path property acts as the source of truth for navigation.
When a button is tapped, a route value is appended to the path. SwiftUI detects this change and searches for a matching navigationDestination.
The route determines which screen should be displayed.
The navigation stack automatically handles:
Push navigation
Back gestures
Removing screens from the stack
Advantages
Simple
There are no additional abstractions or dependencies. Navigation is built entirely using SwiftUI state.
Easy to Understand
All navigation logic exists in one location, making the flow easy to follow.
Programmatic Control
Because navigation is state-driven, you can jump directly to specific screens by modifying the path.
Disadvantages
Scalability Issues
As the application grows, the navigationDestination switch statement can become a large collection of every possible screen.
Tight Coupling
The view managing the path also knows about every destination.
This makes it harder to reuse views across different navigation flows.
Limited Separation of Responsibilities
UI, routing logic, and navigation decisions all live together.
When to Use
Programmatic navigation works well for:
Small applications
Prototypes
Independent feature flows
Apps with only a few screens
2. Coordinator Pattern
What Is the Coordinator Pattern?
The Coordinator pattern separates navigation responsibilities from views.
Instead of views controlling navigation directly, they communicate with a Coordinator object.
The Coordinator owns:
Navigation state
Routing decisions
Push/pop operations
Views only trigger navigation events.
They do not know how navigation is implemented.
The Coordinator
enum Route: Hashable {
case home
case garden
case garage
case kitchen
case bathroom
}
class Coordinator: ObservableObject {
@Published var path = NavigationPath()
func push(_ route: Route) {
path.append(route)
}
func pop() {
path.removeLast()
}
}
Coordinator Root View
struct CoordinatorView: View {
@StateObject private var coordinator = Coordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
HomeView()
.navigationDestination(for: Route.self) { route in
switch route {
case .home:
HomeView()
case .garden:
GardenView()
case .garage:
GarageView()
case .kitchen:
KitchenView()
case .bathroom:
BathroomView()
}
}
}
.environmentObject(coordinator)
}
}
Using the Coordinator From a View
struct HomeView: View {
@EnvironmentObject private var coordinator: Coordinator
var body: some View {
List {
Button("Garden") {
coordinator.push(.garden)
}
Button("Garage") {
coordinator.push(.garage)
}
Button("Kitchen") {
coordinator.push(.kitchen)
}
Button("Bathroom") {
coordinator.push(.bathroom)
}
}
.navigationTitle("Home")
}
}
How It Works
The Coordinator is created at the root of the application and injected using environmentObject.
Child views do not own navigation state.
They simply request navigation through the Coordinator.
This creates a clean separation:
View
|
| sends navigation request
↓
Coordinator
|
| updates path
↓
NavigationStack
Advantages
Separation of Concerns
Views focus on UI. Coordinators handle navigation.
Reusable Views
Views are no longer tied to a specific navigation flow.
The same view can be reused in different parts of the application.
Supports Nested Flows
Large applications can create child coordinators for independent workflows.
Disadvantages
More Boilerplate
You introduce additional types:
Coordinator classes
Route enums
Coordinator root views
Increased Complexity
For simple applications, a Coordinator can introduce unnecessary structure.
Memory Management Considerations
With multiple nested coordinators, developers need to carefully manage object ownership and avoid retain cycles.
When to Use
Coordinator navigation is useful for:
Medium to large applications
Apps with multiple navigation flows
Projects migrating from UIKit where coordinators are already familiar
Although originally popular in UIKit, the Coordinator pattern works well in SwiftUI by combining NavigationStack with dependency injection.
3. TCA Navigation (The Composable Architecture)
What Is TCA Navigation?
The Composable Architecture (TCA) is an open-source architecture from Point-Free that provides a structured approach for building Swift applications.
TCA models navigation as state.
Instead of navigation being controlled by views or coordinator objects, navigation changes happen through reducers.
TCA uses four primary concepts:
State
Represents the current application data.
Action
Represents events that occur:
Button taps
User interactions
API responses
Reducer
Contains the logic that transforms actions into state changes.
Store
Connects the view, state, actions, and reducers.
The navigation flow becomes:
View
↓
Action
↓
Reducer
↓
State Update
↓
View Update
Reducer Example
struct AppFeature {
@ObservableState
struct State: Equatable {
var path = StackState<Path.State>()
}
enum Action {
case gardenTapped
case garageTapped
case kitchenTapped
case bathroomTapped
case path(StackActionOf<Path>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .gardenTapped:
state.path.append(
.garden(GardenFeature.State())
)
return .none
case .garageTapped:
state.path.append(
.garage(GarageFeature.State())
)
return .none
case .kitchenTapped:
state.path.append(
.kitchen(KitchenFeature.State())
)
return .none
case .bathroomTapped:
state.path.append(
.bathroom(BathroomFeature.State())
)
return .none
case .path:
return .none
}
}
.forEach(\.path, action: \.path)
}
}
View Example
struct AppView: View {
@Bindable var store: StoreOf<AppFeature>
var body: some View {
NavigationStack(
path: $store.scope(
state: \.path,
action: \.path
)
) {
HomeView(store: store)
} destination: { store in
switch store.case {
case .garden(let store):
GardenView(store: store)
case .garage(let store):
GarageView(store: store)
case .kitchen(let store):
KitchenView(store: store)
case .bathroom(let store):
BathroomView(store: store)
}
}
}
}
How It Works
The view sends an action:
store.send(.gardenTapped)
The reducer receives that action and updates navigation state.
The view observes the updated state and displays the correct destination.
Navigation becomes a predictable state transition rather than an imperative command.
Advantages
Single Source of Truth
Navigation exists entirely inside application state.
Highly Testable
Navigation decisions are reducer logic, making them easy to verify with unit tests.
Excellent Deep Linking Support
A navigation stack can be created directly from state, making complex deep links easier to implement.
Disadvantages
Steep Learning Curve
TCA introduces an entire architecture that developers must understand.
Over Engineering
For small applications, the additional structure may not provide enough benefit.
Significant Boilerplate
Each feature typically requires:
State
Action
Reducer
View integration
When to Use
TCA navigation works best for:
Large applications
Complex navigation flows
Applications requiring strong testing guarantees
Projects already using TCA for state management
Navigation Approach Comparison
Criteria Programmatic Navigation Coordinator Pattern TCA Navigation Complexity Low Medium High Testing Limited Good Excellent Boilerplate Minimal Moderate Significant Best Fit Small apps Medium-large apps Large applications Dependencies None None TCA library Learning Curve Easy Moderate Steep Deep Linking Manual Manageable Excellent Separation of Concerns Low Good Excellent
Final Thoughts
SwiftUI navigation does not have a single correct architecture. The right choice depends on the size and complexity of your application.
For a small app, NavigationStack and NavigationPath are usually enough.
As navigation grows, the Coordinator pattern provides better organization without introducing a full architecture.
For large applications where navigation, state management, and testing requirements become critical, TCA provides the strongest structure.
The important principle is not choosing the most advanced solution. It is choosing the simplest architecture that can comfortably support your application’s current and future complexity.