Coordinator Pattern — Expert Decisions
Expert decision frameworks for Coordinator pattern choices. Claude knows the pattern — this skill provides judgment calls for when coordinators add value and how to structure hierarchies.
Decision Trees
Do You Need Coordinators?
How many navigation flows does your app have? ├─ 1-2 simple flows │ └─ Skip coordinators │ NavigationStack + simple Router is enough │ ├─ 3-5 distinct flows │ └─ Consider coordinators IF: │ • Flows have complex branching │ • Deep linking is required │ • Flows need to share navigation logic │ └─ 6+ flows or multi-team development └─ Coordinators recommended • Clear ownership boundaries • Parallel development possible • Testable navigation logic
The trap: Adding coordinators to simple apps. If your app is 5 screens with linear flow, coordinators add complexity without benefit.
Coordinator Hierarchy Design
Does this flow need to manage sub-flows? ├─ NO (leaf coordinator) │ └─ Simple coordinator │ Owns NavigationPath, creates views │ └─ YES (has sub-flows) └─ Parent coordinator Manages childCoordinators array Delegates to child for sub-flows
SwiftUI vs UIKit Coordinator
Which UI framework? ├─ SwiftUI │ └─ Coordinator as ObservableObject │ • Owns NavigationPath │ • @ViewBuilder for destinations │ • Pass via EnvironmentObject or explicit injection │ └─ UIKit └─ Coordinator owns UINavigationController • Creates and pushes ViewControllers • Uses delegation for flow completion • Manages childCoordinators manually
Flow Completion Strategy
How does a flow end? ├─ Success (user completed task) │ └─ Delegate method with result │ coordinator.didCompleteLogin(user: user) │ ├─ Cancellation (user backed out) │ └─ Delegate method without result │ coordinator.didCancelLogin() │ └─ Automatic (flow naturally ends) └─ Parent removes child automatically No explicit completion needed
NEVER Do
Child Coordinator Lifecycle
NEVER forget to remove child coordinators:
// ❌ Memory leak — child coordinator retained forever final class ParentCoordinator { var childCoordinators: [Coordinator] = []
func startLoginFlow() {
let loginCoordinator = LoginCoordinator()
childCoordinators.append(loginCoordinator)
loginCoordinator.start()
// Never removed! Leaks.
}
}
// ✅ Remove child on flow completion final class ParentCoordinator: LoginCoordinatorDelegate { var childCoordinators: [Coordinator] = []
func startLoginFlow() {
let loginCoordinator = LoginCoordinator()
loginCoordinator.delegate = self
childCoordinators.append(loginCoordinator)
loginCoordinator.start()
}
func loginCoordinatorDidFinish(_ coordinator: LoginCoordinator) {
childCoordinators.removeAll { $0 === coordinator }
}
}
NEVER use strong parent references:
// ❌ Retain cycle — coordinator never deallocates final class ChildCoordinator { var parent: ParentCoordinator // Strong reference! }
// ✅ Weak parent or delegate final class ChildCoordinator { weak var delegate: ChildCoordinatorDelegate? // OR weak var parent: ParentCoordinator? }
Coordinator Responsibilities
NEVER put business logic in coordinators:
// ❌ Coordinator doing business logic final class CheckoutCoordinator { func completeOrder() async { // Business logic leaked into coordinator! let total = cart.items.reduce(0) { $0 + $1.price } let tax = total * 0.08 try await paymentService.charge(total + tax) } }
// ✅ Coordinator orchestrates, ViewModel/UseCase handles logic final class CheckoutCoordinator { func showCheckout() { let viewModel = CheckoutViewModel( cartService: container.cartService, paymentService: container.paymentService ) // ViewModel handles business logic } }
NEVER let views know about coordinator hierarchy:
// ❌ View knows about parent coordinator struct LoginView: View { let coordinator: LoginCoordinator
var body: some View {
Button("Done") {
coordinator.parent?.childDidFinish(coordinator) // Wrong!
}
}
}
// ✅ View only knows its immediate coordinator struct LoginView: View { let coordinator: LoginCoordinator
var body: some View {
Button("Done") {
coordinator.completeLogin() // Coordinator handles delegation
}
}
}
SwiftUI-Specific
NEVER create coordinators as @StateObject in child views:
// ❌ New coordinator created on every parent rebuild struct ParentView: View { var body: some View { ChildView() // Child creates its own coordinator } }
struct ChildView: View { @StateObject var coordinator = ChildCoordinator() // Wrong! }
// ✅ Parent creates and owns coordinator struct ParentView: View { @StateObject var childCoordinator = ChildCoordinator()
var body: some View {
ChildView(coordinator: childCoordinator)
}
}
NEVER use NavigationLink directly when using coordinators:
// ❌ Bypasses coordinator — navigation untracked struct UserListView: View { var body: some View { NavigationLink("User") { UserDetailView() // Coordinator doesn't know about this! } } }
// ✅ Delegate navigation to coordinator struct UserListView: View { @ObservedObject var coordinator: UsersCoordinator
var body: some View {
Button("User") {
coordinator.showUserDetail(userId: "123")
}
}
}
Essential Patterns
SwiftUI Coordinator Protocol
@MainActor protocol Coordinator: ObservableObject { associatedtype Route: Hashable var path: NavigationPath { get set }
func start() -> AnyView
func navigate(to route: Route)
func pop()
func popToRoot()
}
extension Coordinator { func pop() { guard !path.isEmpty else { return } path.removeLast() }
func popToRoot() {
path = NavigationPath()
}
}
Parent-Child Coordinator
@MainActor protocol ParentCoordinatorProtocol: AnyObject { var childCoordinators: [any Coordinator] { get set } func addChild(_ coordinator: any Coordinator) func removeChild(_ coordinator: any Coordinator) }
extension ParentCoordinatorProtocol { func addChild(_ coordinator: any Coordinator) { childCoordinators.append(coordinator) }
func removeChild(_ coordinator: any Coordinator) {
childCoordinators.removeAll { $0 === coordinator as AnyObject }
}
}
// Tab coordinator managing child coordinators @MainActor final class TabCoordinator: ParentCoordinatorProtocol, ObservableObject { var childCoordinators: [any Coordinator] = []
lazy var homeCoordinator: HomeCoordinator = {
let coordinator = HomeCoordinator()
coordinator.parent = self
addChild(coordinator)
return coordinator
}()
lazy var profileCoordinator: ProfileCoordinator = {
let coordinator = ProfileCoordinator()
coordinator.parent = self
addChild(coordinator)
return coordinator
}()
}
Flow Completion with Result
protocol LoginCoordinatorDelegate: AnyObject { func loginCoordinator(_ coordinator: LoginCoordinator, didFinishWith result: LoginResult) }
enum LoginResult { case success(User) case cancelled }
@MainActor final class LoginCoordinator: ObservableObject { weak var delegate: LoginCoordinatorDelegate? @Published var path = NavigationPath()
enum Route: Hashable {
case credentials
case forgotPassword
case twoFactor(email: String)
}
func completeLogin(user: User) {
delegate?.loginCoordinator(self, didFinishWith: .success(user))
}
func cancel() {
delegate?.loginCoordinator(self, didFinishWith: .cancelled)
}
}
Deep Link Integration
@MainActor final class AppCoordinator: ObservableObject, ParentCoordinatorProtocol { var childCoordinators: [any Coordinator] = [] @Published var path = NavigationPath()
func handle(deepLink: DeepLink) {
// Reset to known state
popToRoot()
childCoordinators.forEach { removeChild($0) }
// Navigate to deep link destination
switch deepLink {
case .user(let id):
navigate(to: .userList)
navigate(to: .userDetail(userId: id))
case .checkout:
let checkoutCoordinator = CheckoutCoordinator()
checkoutCoordinator.delegate = self
addChild(checkoutCoordinator)
// Present checkout flow
case .settings(let section):
navigate(to: .settings)
if let section = section {
navigate(to: .settingsSection(section))
}
}
}
}
Quick Reference
Coordinator Checklist
-
Coordinator owns NavigationPath (SwiftUI) or UINavigationController (UIKit)
-
Parent-child references are weak
-
Child coordinators removed on flow completion
-
Views don't know about coordinator hierarchy
-
Business logic stays in ViewModels/UseCases
-
Deep links handled at appropriate coordinator level
When to Use Coordinators
Scenario Use Coordinator?
Simple 3-5 screen app No — simple Router
Multiple independent flows Yes
Deep linking required Likely yes
Multi-step wizard flows Yes
Cross-tab navigation Yes
A/B testing navigation Yes
Team-based feature ownership Yes
Red Flags
Smell Problem Fix
childCoordinators grows forever Memory leak Remove on completion
Strong parent reference Retain cycle Use weak or delegate
Business logic in coordinator Wrong layer Move to ViewModel/UseCase
View creates NavigationLink Bypasses coordinator Delegate to coordinator
@StateObject coordinator in child Recreated on rebuild Parent owns coordinator
Coordinator creates its own views Can't inject dependencies Use ViewFactory
Coordinator vs Router
Aspect Coordinator Router
Complexity Higher Lower
Hierarchy support Yes (parent-child) No
Flow isolation Strong Weak
Testing Excellent Good
Learning curve Steep Gentle
Best for Large apps, teams Small-medium apps