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.
OrderDetailjust callsonBuyAgain(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.
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();
},
});Hint: Count how many places pop a screen when Save is tapped.
Both the screen and the coordinator pop. Save removes the address form and the screen below it (the checkout summary), so the user lands two screens back, outside checkout, with their order half done. The bug appears only in this flow; in another flow where the coordinator doesn’t pop, the screen’s own pop happens to be right, which is how it sneaks through review.
With coordinators, exactly one place navigates for each event, and it’s the coordinator. The screen reports and does nothing else; it doesn’t even get the navigator:
@objc private func saveTapped() {
onSaved?(form.address) // report only; the coordinator pops
}@Composable
fun AddressFormScreen(onSaved: (Address) -> Unit) { // no NavController parameter
Button(onClick = { onSaved(form.address) }) { Text("Save") }
}saveTapped() {
this.callbacks.onSaved(this.form.address); // report only; the coordinator pops
}Not passing the navigator (NavController, Navigator) into screens at all makes this mistake impossible to write.
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?
The screen only reports onSaved(address). The checkout coordinator pops back to the summary; the settings coordinator shows a banner and stays. Same screen, no changes, no flags. Flags like isFromSettings are the symptom coordinators remove: the screen learning which flow it's in.
- 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
What does a screen do when the user taps an order, in the coordinator pattern?
Why can the same screen appear in several flows?
In Compose, what usually plays the coordinator role?