Skip to content
Commitwise
Menu
Design Patterns

Coordinator

Take navigation out of screens and give it to a coordinator that owns a flow, so screens only report what happened, the same screen works in several flows, child flows finish cleanly and deep links build the right stack, in Swift (UIKit and SwiftUI), Kotlin (Compose) and TypeScript.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The order details screen has a “Buy again” button. In the first version, the screen itself creates the checkout screen and pushes it. Then product wants “Buy again” from the order list too, then from a push notification, then from a widget. Each place copies the push, with slightly different setup. When checkout gets a new first step (choose an address), four screens have to change, and the one in the notification handler is forgotten.

Screens that navigate to other screens know too much: who comes next, how to build it, and what happens after. A Coordinator takes that knowledge out of the screens. Screens only say what happened (“the user tapped Buy again for order 42”); the coordinator decides where that leads.

New to some of these? Open what you need, or skip ahead.

Basics:What is a navigation stack?

Most apps navigate with a stack of screens: opening a screen pushes it on top, and Back pops it off, revealing the one below. iOS uses UINavigationController or SwiftUI’s NavigationStack; Android Compose uses a NavController with a back stack. The iOS lists and navigation and Android navigation tutorials cover them.

Basics:What is a closure or lambda callback?

A callback is a function you hand to someone so they can call you back later. A screen that takes onOrderTap: (String) -> Unit doesn’t know what happens when an order is tapped; whoever created the screen decides by passing the function.

Part Not started

Screens report, coordinators decide

Think of a museum tour. Each room has a guide who explains the paintings, but the guides don’t decide the route; the tour coordinator does. A guide just says “we’re done here”, and the coordinator says which room is next. The same room can be part of the “highlights” tour and the “full day” tour, because the room doesn’t know which tour it’s in.

In an app:

  • Screens show things and report what the user did: “tapped order 42”, “finished checkout”, “cancelled”.
  • The coordinator creates screens, listens to what they report, and decides which screen comes next.

A screen with no navigation code can appear in several flows, and you can test it without building the screens after it.

A Coordinator encapsulates navigation and screen assembly for a flow. Screens (view controllers, composables, views) expose outputs (closures, delegate methods, event lambdas) and never instantiate or present their successors. Coordinators form a tree: an app coordinator starts child coordinators for flows (onboarding, checkout), which report completion with a result and are then released. The pattern originated in UIKit, where UIViewController makes navigation trivially reachable from every screen; in Compose, the equivalent is hoisting navigation out of composables (screens take lambdas; the NavHost graph or a flow-level navigator wires them), and in SwiftUI it’s a router object owning a NavigationStack(path:).

// UIKit: the screen exposes outputs and never pushes anything itself
final class OrderListViewController: UIViewController {
    var onOrderSelected: ((OrderId) -> Void)?
    // in didSelectRowAt: onOrderSelected?(orders[indexPath.row].id)
}

final class OrderDetailViewController: UIViewController {
    var onBuyAgain: ((OrderId) -> Void)?
    init(orderId: OrderId, viewModel: OrderDetailViewModel) { /* … */ super.init(nibName: nil, bundle: nil) }
    required init?(coder: NSCoder) { fatalError() }
}

protocol Coordinator: AnyObject {
    func start()
}

final class OrdersCoordinator: Coordinator {
    private let navigation: UINavigationController
    private let dependencies: AppDependencies
    private var children: [Coordinator] = []

    init(navigation: UINavigationController, dependencies: AppDependencies) {
        self.navigation = navigation
        self.dependencies = dependencies
    }

    func start() {
        let list = OrderListViewController()
        list.onOrderSelected = { [weak self] id in self?.showDetail(id) }
        navigation.pushViewController(list, animated: false)
    }

    func showDetail(_ id: OrderId) {
        let detail = OrderDetailViewController(orderId: id, viewModel: dependencies.orderDetailViewModel(id))
        detail.onBuyAgain = { [weak self] id in self?.startCheckout(reordering: id) }
        navigation.pushViewController(detail, animated: true)
    }

    func startCheckout(reordering id: OrderId) {
        let checkout = CheckoutCoordinator(navigation: navigation, reordering: id, dependencies: dependencies)
        checkout.onFinish = { [weak self, weak checkout] result in
            guard let self, let checkout else { return }
            self.children.removeAll { $0 === checkout }     // release the child flow
            if case .placed(let orderId) = result { self.showDetail(orderId) }
        }
        children.append(checkout)
        checkout.start()
    }
}
// Compose: screens take lambdas; the flow's graph decides what they lead to
@Composable
fun OrderListScreen(viewModel: OrderListViewModel, onOrderSelected: (OrderId) -> Unit) { /* … */ }

@Composable
fun OrderDetailScreen(viewModel: OrderDetailViewModel, onBuyAgain: (OrderId) -> Unit) { /* … */ }

@Serializable data object OrderList
@Serializable data class OrderDetail(val id: String)
@Serializable data class Checkout(val reorderOf: String)

// The coordinator for the orders flow: the only place that navigates
fun NavGraphBuilder.ordersFlow(nav: NavController) {
    composable<OrderList> {
        OrderListScreen(
            viewModel = hiltViewModel(),
            onOrderSelected = { id -> nav.navigate(OrderDetail(id.value)) },
        )
    }
    composable<OrderDetail> {
        OrderDetailScreen(
            viewModel = hiltViewModel(),
            onBuyAgain = { id -> nav.navigate(Checkout(reorderOf = id.value)) },
        )
    }
    checkoutFlow(
        nav = nav,
        onPlaced = { orderId ->
            nav.navigate(OrderDetail(orderId.value)) { popUpTo<OrderList>() }
        },
    )
}
// A framework-neutral coordinator: screens get callbacks, the coordinator owns a Navigator
interface Navigator {
  push(screen: Screen): void;
  pop(): void;
  popTo(predicate: (s: Screen) => boolean): void;
}

class OrdersCoordinator {
  constructor(private readonly nav: Navigator, private readonly deps: AppDependencies) {}

  start() {
    this.nav.push(new OrderListScreen({
      onOrderSelected: (id) => this.showDetail(id),
    }));
  }

  showDetail(id: OrderId) {
    this.nav.push(new OrderDetailScreen(this.deps.orderDetail(id), {
      onBuyAgain: (orderId) => this.startCheckout(orderId),
    }));
  }

  startCheckout(reorderOf: OrderId) {
    new CheckoutCoordinator(this.nav, this.deps, reorderOf, (result) => {
      if (result.kind === "placed") {
        this.nav.popTo((s) => s instanceof OrderListScreen);
        this.showDetail(result.orderId);
      }
    }).start();
  }
}
  • The screens have no idea what comes after them. OrderDetail just calls onBuyAgain(id).
  • The coordinator is the only code that creates screens and navigates. Starting checkout from a notification or a widget means calling startCheckout(reordering:), the same function, with the same steps.
  • Checkout is its own child coordinator. When it finishes, it reports a result (placed or cancelled), and the parent decides what to show next, then lets go of the child.
  • In Compose, the navigation graph for a flow plays the coordinator’s role: screens receive lambdas, and only the graph calls navigate.
Spot the bugSaving an address goes back two screens

Tap the line with the bug.

final class AddressFormViewController: UIViewController {
    var onSaved: ((Address) -> Void)?

    @objc private func saveTapped() {
        let address = form.address
        onSaved?(address)
        navigationController?.popViewController(animated: true)
    }
}

// In CheckoutCoordinator
form.onSaved = { [weak self] address in
    self?.selectedAddress = address
    self?.navigation.popViewController(animated: true)
}
@Composable
fun AddressFormScreen(
    nav: NavController,
    onSaved: (Address) -> Unit,
) {
    Button(onClick = {
        onSaved(form.address)
        nav.popBackStack()
    }) { Text("Save") }
}

// In the checkout graph
composable<AddressForm> {
    AddressFormScreen(nav, onSaved = { address ->
        checkout.select(address)
        nav.popBackStack()
    })
}
class AddressFormScreen {
  constructor(private nav: Navigator, private callbacks: { onSaved(a: Address): void }) {}

  saveTapped() {
    this.callbacks.onSaved(this.form.address);
    this.nav.pop();
  }
}

// In CheckoutCoordinator
new AddressFormScreen(this.nav, {
  onSaved: (address) => {
    this.selectedAddress = address;
    this.nav.pop();
  },
});

Predict the output

The address form is reused in Settings, where saving should show a 'Saved' banner and stay on the form. With the fixed code, what has to change in AddressFormScreen?

  • Coordinators own navigation and screen assembly per flow; screens expose outputs (closures, delegates, lambdas) and never present successors.
  • Coordinator tree: parent starts child flows, child reports a result, parent releases it.
  • Compose: hoist navigation out of composables (screens take lambdas, the graph navigates); SwiftUI: a router owning NavigationStack(path:).
  • Exactly one place navigates per event: don’t pass the navigator into screens.
  • Payoff: reusable screens without “which flow am I in” flags, testable flows, one entry point per flow.

Check this part

  1. What does a screen do when the user taps an order, in the coordinator pattern?

  2. Why can the same screen appear in several flows?

  3. In Compose, what usually plays the coordinator role?

A coordinator owns navigation for one flow: it creates screens, gives them callbacks, and decides what comes next when they report events. Screens never create or push other screens, so they can be reused in several flows and tested alone. Only one place navigates for each event.

Key takeaways

  • Screens report what happened; the coordinator decides where to go.
  • Only one place navigates for each event, so don’t give screens the navigator.
  • Each flow has its own coordinator, released when the flow ends.
  • Deep links call the same coordinator methods as taps do.
  • Outputs on screens, assembly and navigation in coordinators, organized as a tree.
  • Finish child flows on every exit path and avoid reference cycles.
  • Centralize deep links, restoration and auth gating in coordinators.
  • With state-driven navigation, coordinators become routers that own navigation state.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.