What is a coordinator or router, and how does it compare with SwiftUI’s NavigationStack for deciding who owns navigation?
Predict first
A router’s stack after pushes, a pop, and a deep link replacing the stack:
enum Route { case list, detail(Int), settings }
final class Router {
private(set) var stack: [Route] = [.list]
func push(_ r: Route) { stack.append(r) }
func pop() { if stack.count > 1 { stack.removeLast() } }
func open(deepLink: String) {
let parts = deepLink.split(separator: "/")
if parts.count == 2, parts[0] == "item", let id = Int(parts[1]) { stack = [.list, .detail(id)] }
}
}
let r = Router()
r.push(.detail(3)); r.push(.settings); r.pop()
print(r.stack.map { String(describing: $0) })
r.open(deepLink: "item/7")
print(r.stack.map { String(describing: $0) })
- ["list", "detail(3)"] then ["list", "detail(7)"]
- ["list", "detail(3)", "settings"] then ["list", "detail(7)"]
- ["list"] then ["list"]
Mental model. A coordinator is a tour guide: the sights (screens) do not know the itinerary, they just say “I’m done”, and the guide decides the next stop, hands out the tickets (dependencies) and can start the tour at stop four when asked.
If each screen decides where to go next, screens know about each other and cannot be reused, tested or reordered. A coordinator (or router) takes that job: a screen (or its view model) reports an intent (onSelect(id), onFinish) through a closure or delegate, and the coordinator creates the next screen, injects its dependencies, and presents or pushes it. Benefits: screens are decoupled and reusable, a flow (onboarding, checkout) lives in one place with its own state, deep links become “build the stack for this route”, dependency wiring is centralised, and view controllers or views stop importing each other. In UIKit a coordinator owns a UINavigationController and child coordinators (kept in an array so they are not deallocated, and removed when they finish, which is the usual source of leaks). In SwiftUI, NavigationStack with a path you own gives the same capability: model the destinations as a Hashable Route enum, keep the path in an observable router object, and map routes to views with navigationDestination(for:); pushing is appending a route, popping is removing one, and a deep link sets the whole path (the SwiftUI navigation question covers the mechanics).
So the ownership rule: navigation state is owned by something above the screens: a router or coordinator object, or the parent feature’s state in a unidirectional design, and screens only emit events. A simple router is a plain value type you can test: pushes and pops change a stack of routes, popToRoot resets it, and a deep link replaces it (so myapp://item/7 becomes the stack [list, detail(7)]). Keep routes small (ids, not whole objects), define who handles which routes (a root router delegating to feature routers), handle modals and tab switches as routes too, and keep the router free of business logic. Avoid the extremes: a coordinator for a two-screen app is ceremony, and a single global singleton router couples everything. Choose per size: small SwiftUI apps can use NavigationStack and a path in a model; larger apps benefit from feature-level coordinators with a clear parent and child hierarchy; and either way keep the “where next” decision out of the leaf screen.
The settings push was popped, leaving list and detail 3. The deep link replaces the whole stack.
Common traps
- Child coordinators never removed when finished. They stay in the parent’s array and leak with their screens.
- Screens that construct and push the next screen themselves. They become coupled and cannot be reused or tested alone.
They might follow up with
- How does a deep link fit? It becomes a route: the router builds the stack of routes for that link.
- What is the SwiftUI equivalent of a coordinator? A router object owning the
NavigationStackpath and the route enum.
Related questions
- 15.1.1 How do MVC, MVVM, MVP, VIPER, RIBs and Clean compare, and which would you choose?
- 15.2.2 How do you do dependency injection in Swift, and when do you need a container or framework?
- 8.6.2 How does NavigationStack work, and how do NavigationPath and navigationDestination fit together?
How do you do dependency injection in Swift, and when do you need a… →