Navigation as State: Applying the Coordinator Pattern in SwiftUI
Why SwiftUI changes how coordinators work—and how to design them properly.

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
UINavigationControllerPush 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
@PublishedstateExposes 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
switchon 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 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.





