How to Implement the Coordinator Pattern in SwiftUI
Create an @Observable coordinator class that owns a
NavigationPath, expose push/pop/popToRoot helpers,
then bind it to NavigationStack(path:) and inject it into the SwiftUI environment so any child view can trigger navigation without tight coupling.
// 1. Route enum
enum AppRoute: Hashable {
case detail(id: String)
case settings
}
// 2. Observable coordinator
@Observable
class AppCoordinator {
var path = NavigationPath()
func push(_ route: AppRoute) { path.append(route) }
func pop() { if !path.isEmpty { path.removeLast() } }
func popToRoot() { path.removeLast(path.count) }
}
// 3. Root view
struct RootView: View {
@State private var coordinator = AppCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .detail(let id): DetailView(id: id)
case .settings: SettingsView()
}
}
}
.environment(coordinator)
}
}
Full implementation
The pattern below uses Swift's @Observable macro (iOS 17+) instead of the older ObservableObject to avoid spurious re-renders.
A Hashable AppRoute enum covers every destination, and a single
navigationDestination(for:) call switches on that enum — keeping all route-to-view mapping in one place rather than scattered across the view hierarchy.
Deep-link support comes for free: just call coordinator.push(.detail(id: "42")) from anywhere that holds the environment value.
import SwiftUI
// MARK: - Routes
enum AppRoute: Hashable {
case detail(id: String, title: String)
case settings
case profile(userID: String)
}
// MARK: - Coordinator
@Observable
final class AppCoordinator {
var path = NavigationPath()
func push(_ route: AppRoute) {
path.append(route)
}
func pop() {
guard !path.isEmpty else { return }
path.removeLast()
}
func popToRoot() {
path.removeLast(path.count)
}
// Convenience: replace entire stack
func replace(with routes: [AppRoute]) {
path = NavigationPath(routes)
}
}
// MARK: - Environment Key
extension EnvironmentValues {
@Entry var coordinator: AppCoordinator = AppCoordinator()
}
// MARK: - Root
struct CoordinatorRootView: View {
@State private var coordinator = AppCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
destinationView(for: route)
}
}
.environment(\.coordinator, coordinator)
}
@ViewBuilder
private func destinationView(for route: AppRoute) -> some View {
switch route {
case .detail(let id, let title):
DetailView(id: id, title: title)
case .settings:
SettingsView()
case .profile(let userID):
ProfileView(userID: userID)
}
}
}
// MARK: - Home
struct HomeView: View {
@Environment(\.coordinator) private var coordinator
let items = ["Alpha", "Beta", "Gamma"]
var body: some View {
List(items, id: \.self) { item in
Button(item) {
coordinator.push(.detail(id: item.lowercased(), title: item))
}
.accessibilityLabel("Open \(item) detail")
}
.navigationTitle("Home")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Settings") { coordinator.push(.settings) }
.accessibilityLabel("Open settings")
}
}
}
}
// MARK: - Detail
struct DetailView: View {
@Environment(\.coordinator) private var coordinator
let id: String
let title: String
var body: some View {
VStack(spacing: 24) {
Text("Detail: \(title)")
.font(.title2.bold())
Button("View Profile") {
coordinator.push(.profile(userID: id))
}
.buttonStyle(.borderedProminent)
.accessibilityLabel("Open profile for \(title)")
Button("Back to Root") { coordinator.popToRoot() }
.foregroundStyle(.secondary)
}
.navigationTitle(title)
.padding()
}
}
// MARK: - Settings
struct SettingsView: View {
@Environment(\.coordinator) private var coordinator
var body: some View {
Form {
Section("Navigation") {
Button("Pop to Root") { coordinator.popToRoot() }
.foregroundStyle(.red)
}
}
.navigationTitle("Settings")
}
}
// MARK: - Profile
struct ProfileView: View {
let userID: String
var body: some View {
Text("Profile: \(userID)")
.navigationTitle("Profile")
.padding()
}
}
// MARK: - Preview
#Preview {
CoordinatorRootView()
}
How it works
-
AppRoute (Hashable enum). Every screen is a case — including associated values for typed parameters like
.detail(id:title:).NavigationPathstores type-erased values, but conforming toHashableletsnavigationDestination(for: AppRoute.self)decode them back to the concrete type at render time. -
@Observable AppCoordinator. The
@Observablemacro (replacingObservableObject) tracks only the properties that a view actually reads, so swappingpathonly re-renders theNavigationStack, not every consumer in the environment. -
NavigationStack(path: $coordinator.path). The
$binding wires the stack to the coordinator's path. Appending or removing frompathdrives the stack programmatically — noNavigationLinkrequired in child views. -
@Entry EnvironmentValues extension. The
@Entrymacro (Swift 5.10 / iOS 17) eliminates the boilerplateEnvironmentKeystruct. Any view that declares@Environment(\.coordinator)can callpush/popwithout needing a direct reference to the parent. -
destinationView(for:) switch. All route-to-view mapping lives in one
@ViewBuilderhelper insideCoordinatorRootView. Adding a new screen means adding a case toAppRouteand one case in this switch — the compiler enforces exhaustiveness.
Variants
Deep linking via onOpenURL
Map incoming URLs directly to coordinator pushes. Because the coordinator is a plain class you can call this before the first frame renders.
struct CoordinatorRootView: View {
@State private var coordinator = AppCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
destinationView(for: route)
}
}
.environment(\.coordinator, coordinator)
.onOpenURL { url in
handleDeepLink(url)
}
}
private func handleDeepLink(_ url: URL) {
// e.g. soarias://profile/abc123
guard url.scheme == "soarias",
url.host == "profile",
let userID = url.pathComponents.dropFirst().first
else { return }
coordinator.replace(with: [.profile(userID: userID)])
}
}
Tab-scoped coordinators
For a tab bar app, give each tab its own AppCoordinator instance stored in the parent's @State. Each tab's NavigationStack binds to its own coordinator, so navigating in one tab never clobbers another. Inject each coordinator with a distinct environment key (or a sub-key like \.feedCoordinator) to keep them isolated.
Common pitfalls
-
⚠️ iOS 16 NavigationPath can't decode associated values. The approach works as written only on iOS 17+, where
@Observableand@Entryare available. On iOS 16 you'd needObservableObject+ a customEnvironmentKeystruct — not a drop-in swap. -
⚠️ Never place a second NavigationStack inside a destination view. SwiftUI logs a purple warning and the inner stack's path is unmanaged. If a destination itself needs a sub-stack (e.g., a modal flow), present it as a
.sheetwith its own scoped coordinator rather than nesting stacks. -
⚠️ NavigationPath is not Codable by default with enums containing non-Codable associated values. If you need state restoration, conform
AppRoutetoCodableas well and useNavigationPath.CodableRepresentationto persist and restore the stack across launches.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement coordinator pattern in SwiftUI for iOS 17+. Use Coordinator (as @Observable class) and NavigationPath. Define an AppRoute: Hashable enum for all destinations. Inject the coordinator via @Entry EnvironmentValues. Make it accessible (VoiceOver labels on all navigation buttons). Add a #Preview with realistic sample data and at least 3 routes.
Paste this prompt during Soarias's Build phase after screens are scaffolded — the coordinator centralises navigation so Claude Code can add new routes without touching existing view files.
Related
FAQ
Does this work on iOS 16?
Not as written. @Observable and @Entry require iOS 17. For iOS 16 compatibility, replace @Observable with ObservableObject + @Published, and write a manual EnvironmentKey struct. NavigationPath itself is available from iOS 16.
How do I present a modal (sheet/fullScreenCover) from the coordinator?
Add an optional @Published var sheet: AppRoute? (or a separate enum) to the coordinator, then bind it with .sheet(item: $coordinator.sheet) on the root view. This keeps modal state centralised alongside push navigation while still using SwiftUI's built-in presentation system — you don't need a second NavigationStack inside the sheet.
What is the UIKit equivalent?
In UIKit the coordinator pattern (popularised by Soroush Khanlou) wraps a UINavigationController and implements a start() method that pushes the first view controller, with child coordinators for sub-flows. The SwiftUI version maps almost one-to-one: NavigationStack ↔ UINavigationController, AppRoute ↔ the routable protocol, and environment injection ↔ passing the coordinator as a delegate or closure.
Last reviewed: 2026-05-12 by the Soarias team.