Skip to main content

Command Palette

Search for a command to run...

Navigation as State: Applying the Coordinator Pattern in SwiftUI

Why SwiftUI changes how coordinators work—and how to design them properly.

Updated
5 min read
Navigation as State: Applying the Coordinator Pattern in SwiftUI

When apps grow, navigation becomes messy.

At first, it’s just:

  • show login

  • push home

  • present details

But as features increase, flows start crossing each other:

  • Splash → Auth → Home

  • Auth → Forgot password → OTP → Reset

  • Home → Profile → Edit → Back

  • Deep links

  • Logout from anywhere

If navigation logic lives inside views, things quickly become hard to reason about.

That’s where the Coordinator Pattern helps.


What Is the Coordinator Pattern?

The coordinator pattern is a way to move navigation logic out of views and into dedicated objects called coordinators.

Instead of views deciding:

“When button is tapped, push this screen”

They say:

“Hey coordinator, user tapped login”

And the coordinator decides what happens next.

In short:

Coordinators control navigation flow.
Views describe UI only.


Thinking of Navigation as State

In SwiftUI especially, navigation is just state.

Instead of saying:

  • push screen A

  • present screen B

We say:

  • currentRoute = .login

  • currentRoute = .home

  • authMode = .signup

UI simply reacts to that state.

So coordinator becomes:

  • A state holder

  • A flow controller

  • A source of truth for navigation


High-Level Flow

Here’s a simple app flow:

This is a very simple app:

  • Splash screen checks login

  • If logged in → Home

  • If not → Auth

  • Auth switches between Login and Signup

  • After login → Home

Now let’s see how this was done in UIKit and how it differs in SwiftUI.


Coordinator Pattern in UIKit

In UIKit, coordinators usually:

  • Own a UINavigationController

  • Push and present view controllers

  • Directly decide what to show

Example idea:

class AppCoordinator {
    let navigationController: UINavigationController

    func start() {
        showSplash()
    }

    func showSplash() {
        let vc = SplashViewController()
        vc.onFinish = { [weak self] isLoggedIn in
            if isLoggedIn {
                self?.showHome()
            } else {
                self?.showAuth()
            }
        }
        navigationController.setViewControllers([vc], animated: false)
    }

    func showHome() {
        let homeVC = HomeViewController()
        navigationController.setViewControllers([homeVC], animated: true)
    }
}

Key Point in UIKit

Coordinator decides:

  • What view controller to create

  • When to push

  • When to present

  • When to replace stack

It fully controls navigation stack.

UIKit = Imperative navigation

“Push this now.”


Coordinator Pattern in SwiftUI

SwiftUI works differently.

Navigation is state-driven.

Instead of pushing screens, we change state and SwiftUI updates the UI.

So in SwiftUI:

Coordinator does NOT push views.

Instead, it:

  • Exposes @Published state

  • Exposes methods to modify state

  • Root view decides what to render

UIKit coordinator:

“Show Home”

SwiftUI coordinator:

currentRoute = .home

And the view reacts.

SwiftUI = Declarative navigation

“When route is .home, show HomeView.”


Folder Structure for a Large SwiftUI App

Let’s imagine this simple app:

  • Splash

  • Auth (Login / Signup)

  • Home

A clean structure might look like:

App/
 ├── AppEntry.swift
 ├── RootCoordinator.swift
 ├── RootView.swift

Features/
 ├── Splash/
 │    ├── SplashView.swift
 │
 ├── Auth/
 │    ├── AuthCoordinator.swift
 │    ├── AuthView.swift
 │    ├── LoginView.swift
 │    └── SignupView.swift
 │
 ├── Home/
 │    └── HomeView.swift

Notice:

  • Root has its own coordinator

  • Auth has its own coordinator

  • Features are separated

This scales well for large apps.


Step 1: Root Coordinator (SwiftUI Version)

Root decides between:

  • Splash

  • Auth

  • Home

enum RootRoute {
    case splash
    case auth
    case home
}

final class RootCoordinator: ObservableObject {
    @Published var route: RootRoute = .splash

    func decideInitialFlow(isLoggedIn: Bool) {
        route = isLoggedIn ? .home : .auth
    }

    func didLogin() {
        route = .home
    }

    func logout() {
        route = .auth
    }
}

This coordinator does not create views.

It only changes state.


Step 2: Root View Decides What to Show

struct RootView: View {
    @StateObject var coordinator = RootCoordinator()

    var body: some View {
        switch coordinator.route {
        case .splash:
            SplashView {
                coordinator.decideInitialFlow(isLoggedIn: $0)
            }

        case .auth:
            AuthView(
                onLoginSuccess: {
                    coordinator.didLogin()
                }
            )

        case .home:
            HomeView(
                onLogout: {
                    coordinator.logout()
                }
            )
        }
    }
}

Notice the difference from UIKit:

  • No push

  • No navigation controller

  • Just a switch on state

SwiftUI handles rendering.


Step 3: Auth Has Its Own Coordinator

Inside Auth, we want to switch between:

  • Login

  • Signup

So we create an AuthCoordinator.

enum AuthMode {
    case login
    case signup
}

final class AuthCoordinator: ObservableObject {
    @Published var mode: AuthMode = .login

    func showSignup() {
        mode = .signup
    }

    func showLogin() {
        mode = .login
    }
}

AuthView Uses AuthCoordinator

struct AuthView: View {
    @StateObject private var coordinator = AuthCoordinator()
    var onLoginSuccess: () -> Void

    var body: some View {
        switch coordinator.mode {
        case .login:
            LoginView(
                onSignupTapped: {
                    coordinator.showSignup()
                },
                onLoginSuccess: {
                    onLoginSuccess()
                }
            )

        case .signup:
            SignupView(
                onBackToLogin: {
                    coordinator.showLogin()
                }
            )
        }
    }
}

Now Auth handles its own internal navigation.

Root doesn’t care whether Auth is showing login or signup.

This is separation of responsibility.


The Big Difference (UIKit vs SwiftUI)

UIKit CoordinatorSwiftUI Coordinator
Owns navigation controllerOwns navigation state
Pushes view controllersUpdates published state
ImperativeDeclarative
Controls stack directlyUI reacts to state

UIKit:

“Push LoginViewController”

SwiftUI:

route = .login

This is the mindset shift.