← All topics/Architecture & design patterns

Question 2

Coordinators and 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 deeplinks or tab bars?

Answer outline

A coordinator (or router) owns the navigation stack and the flow through it. It creates view controllers or views, injects their dependencies, and reacts to the events they emit. View controllers stop knowing how the next screen is built. One type decides who presents whom, whether a screen is pushed or shown modally, and who owns each object's lifetime.

Deeplinks flow through the same structure. Parse the URL into a route value, hand it to the root coordinator, and let it switch to the right tab and push the matching stack. No view controller needs to know the URL format.

The tradeoff is structure vs ceremony. Coordinators make navigation explicit and testable, but they add files, delegation, and one more place to look when you debug a flow.

They earn their keep when flows are reused, reached by deeplink, split across tabs, or hard to follow from one view controller. For a one-screen feature they're overkill. Keep the pattern light: one coordinator per feature or flow, small route enums, children released when a flow finishes, and tests on the important paths.

Principles

  • The coordinator owns the navigation stack, and view controllers never push or present themselves.
  • The parent creates each child coordinator and releases it once the child reports completion through a closure or delegate.
  • In UIKit, keep children in a childCoordinators array on the parent and remove each one when its flow ends.
  • Avoid singleton coordinators, and scope each one to a window scene, tab, or feature flow.

Routes are Hashable so NavigationStack can use them, and the coordinator exposes navigate and complete so views never touch the path array:

Route enum and coordinator
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:

Flow view: NavigationStack bound to the coordinator path
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 strong reference to the coordinator, and no weak reference is needed because the coordinator never holds a view:

Child view: navigates through the coordinator
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 the parent
            }
        }
        .navigationTitle("Sign In")
    }
}

The app coordinator holds the active child, and setting it to nil releases the flow and lets SwiftUI swap to the next screen:

Parent coordinator: creates and releases the child
@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 the 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

  • With a tab bar, the app coordinator owns one child coordinator per tab, each with its own navigation stack. A deeplink selects the tab first and then hands the rest of the route to that tab's coordinator.
  • Don't let every view controller reach for UIApplication.shared to find the window. That hides dependencies from tests and breaks in multi-window apps, where one process has several scenes.