Question 2
Coordinators & navigation ownership
You’re hitting retain cycles and inconsistent back-stack behavior when view controllers push each other. How does a coordinator style flow help, and what are the tradeoffs?
Follow-ups
- How does this interact with deep links or tab bars?
Answer outline
A coordinator (or router) owns the navigation stack and child flow: it creates VCs/Views, injects dependencies, and responds to events.
View controllers stop knowing how the next screen is constructed, reducing tight coupling and duplicate init spaghetti.
Practically you fix: who presents whom, modal vs push rules, and memory ownership.
Deeplinks become: parse URL → ask the root coordinator to switch tab and push a stack.
The main tradeoff is structure vs ceremony: coordinators make navigation explicit and testable, but they add more files, more delegation, and another place to look when debugging a flow.
They are worth it when flows are reused, deep-linked, split across tabs, or hard to reason about from a single view controller. For a one-screen feature, a coordinator can be overkill.
Keep the pattern lightweight: use one coordinator per feature or flow, keep route enums small, release child coordinators when flows finish, and test important navigation paths rather than every push.
Principles
- The coordinator owns the navigation controller — view controllers never push or present themselves.
- Child coordinators report up via closure or delegate; the parent releases the child on completion.
- Store children in a
childCoordinatorsarray on the parent and remove ononCompleteto prevent leaks. - Avoid singleton coordinators; scope them to a window scene, tab, or feature flow.
Routes must be Hashable for NavigationStack; the coordinator owns the path and exposes navigate/complete rather than letting views touch the array directly:
enum AuthRoute: Hashable {
case forgotPassword
case register
}
@Observable
final class AuthCoordinator {
var path: [AuthRoute] = []
var onComplete: (() -> Void)?
func navigate(to route: AuthRoute) {
path.append(route)
}
func complete() {
onComplete?()
}
}
The flow view binds NavigationStack to the coordinator path and maps each route to its destination view:
struct AuthFlow: View {
let coordinator: AuthCoordinator
var body: some View {
NavigationStack(path: Bindable(coordinator).path) {
LoginView(coordinator: coordinator)
.navigationDestination(for: AuthRoute.self) { route in
switch route {
case .forgotPassword:
ForgotPasswordView(coordinator: coordinator)
case .register:
RegisterView(coordinator: coordinator)
}
}
}
}
}
Views hold a plain (strong) reference to the coordinator and call its methods — no weak reference needed since the flow view owns the coordinator lifetime:
struct LoginView: View {
let coordinator: AuthCoordinator
var body: some View {
VStack(spacing: 16) {
Button("Forgot password?") {
coordinator.navigate(to: .forgotPassword)
}
Button("Sign in") {
coordinator.complete() // bubbles up to parent
}
}
.navigationTitle("Sign In")
}
}
The app coordinator holds the active child coordinator; setting it to nil releases the flow and SwiftUI swaps to the next screen:
@Observable
final class AppCoordinator {
private(set) var authCoordinator: AuthCoordinator?
private(set) var isAuthenticated = false
init() { showAuth() }
private func showAuth() {
let child = AuthCoordinator()
child.onComplete = { [weak self] in
self?.authCoordinator = nil // releases child
self?.isAuthenticated = true
}
authCoordinator = child
}
}
struct AppView: View {
@State private var coordinator = AppCoordinator()
var body: some View {
if let auth = coordinator.authCoordinator {
AuthFlow(coordinator: auth)
} else {
MainView()
}
}
}
Follow-up angles
- Tab bar: app coordinator owns tab coordinators; deep link selects tab then delegates to feature coordinator.
- Don’t let every VC reach
UIApplication.shared—that hides testability and multi-window bugs.