What is the Coordinator pattern and what problem does it solve?
TL;DR
In plain UIKit, each view controller creates and pushes the next one. Screens become tightly coupled. A coordinator is an object that owns the navigation flow. View controllers only report events, like "user tapped login". The coordinator decides where to go next.
The problem
// Inside LoginViewController — it knows too much
func didLogin() {
let home = HomeViewController(api: API(), user: user)
navigationController?.pushViewController(home, animated: true)
}Now LoginViewController depends on HomeViewController. You cannot reuse it in another flow, like onboarding.
A basic coordinator
@MainActor
protocol Coordinator: AnyObject {
var childCoordinators: [Coordinator] { get set }
func start()
}
@MainActor
final class AuthCoordinator: Coordinator {
var childCoordinators: [Coordinator] = []
var onFinish: ((User) -> Void)?
private let navigationController: UINavigationController
init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func start() {
let login = LoginViewController()
login.onLogin = { [weak self] user in self?.onFinish?(user) }
login.onForgotPassword = { [weak self] in self?.showReset() }
navigationController.pushViewController(login, animated: false)
}
private func showReset() {
let reset = ResetPasswordViewController()
navigationController.pushViewController(reset, animated: true)
}
}The view controller is now simple:
final class LoginViewController: UIViewController {
var onLogin: ((User) -> Void)?
var onForgotPassword: (() -> Void)?
}Parent and child coordinators
An AppCoordinator starts child flows and removes them when they finish.
@MainActor
final class AppCoordinator: Coordinator {
var childCoordinators: [Coordinator] = []
private let nav = UINavigationController()
func start() {
let auth = AuthCoordinator(navigationController: nav)
auth.onFinish = { [weak self, weak auth] user in
self?.childCoordinators.removeAll { $0 === auth }
self?.showHome(for: user)
}
childCoordinators.append(auth)
auth.start()
}
private func showHome(for user: User) { /* start HomeCoordinator */ }
}The childCoordinators array keeps children alive. Forget to remove them and you leak memory.
The back button problem
When the user taps the system back button, the coordinator is not told.
Fix it by making the coordinator the UINavigationControllerDelegate and checking which controller was popped:
func navigationController(_ nav: UINavigationController,
didShow vc: UIViewController, animated: Bool) {
guard let from = nav.transitionCoordinator?.viewController(forKey: .from),
!nav.viewControllers.contains(from) else { return }
// `from` was popped. Clean up its child coordinator here.
}Benefits
- View controllers are reusable in different flows.
- Navigation logic is in one place and easy to read.
- Deep links are easier: the coordinator builds the right stack.
- Dependencies are created in the coordinator and injected into screens.
Downsides
- More types and some boilerplate.
- Memory bugs if child coordinators are not removed.
- Back-button and swipe-back handling need extra code.
In SwiftUI
The same idea becomes a router that owns a NavigationStack path:
@MainActor @Observable
final class Router {
var path: [Route] = []
func showSettings() { path.append(.settings) }
}Views call router.showSettings() and never create the next screen themselves.
Common follow-up questions
- Should view controllers hold a strong reference to the coordinator? No. Use closures or a weak delegate to avoid cycles.
- Who owns the root coordinator? The
SceneDelegate(orApp) keeps it in a property. - How does it work with MVVM? The view model raises events. The coordinator listens and navigates. This is often called MVVM-C.
- How do you handle modals? The coordinator presents them and often starts a new child coordinator with its own navigation controller.