• Jul 12

SwiftUI Navigation Patterns - Programmatic Navigation vs Coordinator vs TCA

  • DevTechie

SwiftUI provides powerful navigation tools like NavigationStack, NavigationPath, and navigationDestination, but how you organize navigation logic determines the scalability and maintainability of your app. Explore three approaches to SwiftUI navigation—programmatic navigation, the Coordinator pattern, and TCA navigation—and understand their trade-offs, benefits, and ideal use cases.

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:

  1. Programmatic Navigation using NavigationStack and NavigationPath

  2. The Coordinator Pattern

  3. 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.