# 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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1770782846435/871273f9-8e92-4184-bb79-89cdc044d391.png align="center")

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:

```swift
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:

```markdown
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
    

```swift
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

```swift
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`.

```swift
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

```swift
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 Coordinator | SwiftUI Coordinator |
| --- | --- |
| Owns navigation controller | Owns navigation state |
| Pushes view controllers | Updates published state |
| Imperative | Declarative |
| Controls stack directly | UI reacts to state |

UIKit:

> “Push LoginViewController”

SwiftUI:

> `route = .login`

This is the mindset shift.
