The coordinator pattern in the Composable Architecture
TCACoordinators brings a flexible approach to navigation in SwiftUI using the Composable Architecture (TCA). It allows you to manage complex navigation and presentation flows with a single piece of state, hoisted into a high-level coordinator. Using this pattern, you can write isolated screen features that have zero knowledge of their context within the navigation flow of an app. It achieves this by combining existing tools in TCA with a novel approach to handling navigation in SwiftUI.
You might like this library if you want to:
✅ Support deeplinks into deeply nested navigation routes in your app.
✅ Easily reuse screen features within different navigation contexts.
✅ Easily go back to the root screen or a specific screen in the navigation stack.
✅ Keep all navigation logic in a single place.
✅ Break an app's navigation into multiple reusable coordinators and compose them together.
✅ Use a single system to unify push navigation and modal presentation.
The library works by translating the array of screens into a hierarchy of NavigationStacks and presentation calls, so:
🚫 It does not rely on UIKit at all.
🚫 It does not try to recreate SwiftUI navigation from scratch.
First, identify all possible screens that are part of the particular navigation flow you're modelling. The goal will be to combine their reducers into a single reducer - one that can drive the behaviour of any of those screens. Thanks to the @Reducer macro, this can be easily achieved with an enum reducer, e.g. the following (where Home, NumbersList and NumberDetail are the individual screen reducers):
@Reducer(state: .hashable)
enum Screen {
case home(Home)
case numbersList(NumbersList)
case numberDetail(NumberDetail)
}The coordinator will manage multiple screens in a navigation flow. Its state should include an array of Route<Screen.State>s, representing the navigation stack: i.e. appending a new screen state to this array will trigger the corresponding screen to be pushed or presented. Route is an enum whose cases capture the screen state and how it should be shown, e.g. case push(Screen.State).
@Reducer
struct Coordinator {
@ObservableState
struct State: Equatable {
var routes: [Route<Screen.State>]
}
...
}The coordinator's action should include a special case, which will allow screen actions to be dispatched to the correct screen in the routes array, and allow the routes array to be updated automatically, e.g. when a user taps 'Back':
@Reducer
struct Coordinator {
...
enum Action {
case router(IndexedRouterActionOf<Screen>)
}
...
}The coordinator reducer defines any logic for presenting and dismissing screens, and uses forEachRoute to further apply the Screen reducer to each screen in the routes array. forEachRoute takes two arguments: a keypath for the routes array and a case path for the router action case:
@Reducer
struct Coordinator {
...
var body: some ReducerOf<Self> {
Reduce<State, Action> { state, action in
switch action {
case .router(.routeAction(_, .home(.startTapped))):
state.routes.presentSheet(.numbersList(.init(numbers: Array(0 ..< 4))), embedInNavigationView: true)
case .router(.routeAction(_, .numbersList(.numberSelected(let number)))):
state.routes.push(.numberDetail(.init(number: number)))
case .router(.routeAction(_, .numberDetail(.showDouble(let number)))):
state.routes.presentSheet(.numberDetail(.init(number: number * 2)))
case .router(.routeAction(_, .numberDetail(.goBackTapped))):
state.routes.goBack()
default:
break
}
return .none
}
.forEachRoute(\.routes, action: \.router)
}
}With that in place, a CoordinatorView can be created. It will use a TCARouter, which translates the array of routes into a nested list of screen views with invisible NavigationLinks and presentation calls, all configured with bindings that react appropriately to changes to the routes array. As well as a scoped store, the TCARouter takes a closure that can create the view for any screen in the navigation flow. A switch statement is the natural way to achieve that, with a case for each of the possible screens:
struct CoordinatorView: View {
let store: StoreOf<Coordinator>
var body: some View {
TCARouter(store.scope(state: \.routes, action: \.router)) { screen in
switch screen.case {
case let .home(store):
HomeView(store: store)
case let .numbersList(store):
NumbersListView(store: store)
case let .numberDetail(store):
NumberDetailView(store: store)
}
}
}
}The routes array can be managed using normal Array methods such as append, but a number of convenience methods are available for common transformations, such as:
| Method | Effect |
|---|---|
| push | Pushes a new screen onto the stack. |
| presentSheet | Presents a new screen as a sheet.† |
| presentCover | Presents a new screen as a full-screen cover.† |
| goBack | Goes back one screen in the stack. |
| goBackToRoot | Goes back to the very first screen in the stack. |
| goBackTo | Goes back to a specific screen in the stack. |
| pop | Pops the current screen if it was pushed. |
| dismiss | Dismisses the most recently presented screen. |
† Pass embedInNavigationView: true if you want to be able to push screens from the presented screen.
If the user taps the back button, the routes array will be automatically updated to reflect the new navigation state. Navigating back with an edge swipe gesture or via a long-press gesture on the back button will also update the routes array automatically, as will swiping to dismiss a sheet.
By default, any in-flight effects initiated by a particular screen are cancelled automatically when that screen is popped or dismissed. To opt out of automatic cancellation, pass cancellationId: nil to forEachRoute.
SwiftUI does not support presenting more than one screen within a single state update. Before the NavigationStack APIs were introduced, the same was true of pushing more than one screen in a single state update. This made it difficult to deep-linking to a screen multiple layers deep in a navigation hierarchy. With TCACoordinators, you can make any such changes, and the library will, behind the scenes, break down the larger update into a series of smaller updates that SwiftUI supports, with delays added if necessary in between.
The coordinator is just like any other UI unit in the Composable Architecture - comprising a View and a Reducer with State and Action types. This means they can be composed in the normal ways SwiftUI and TCA allow. However, there are some limitations to keep in mind when breaking your app's screen flows into several distinct flows of related screens: see Nesting Coordinators.
In the example given, the Coordinator.Action's router case included an associated value of IndexedRouterActionOf<Screen>. That means that screens were identified by their index in the routes array. This is safe because the index is stable for standard navigation updates - e.g. pushing and popping do not affect the indexes of existing screens. However, if you prefer to use Identifiable screens, you can manage the screens as an IdentifiedArray instead. The Coordinator.Action's router case will then have an associated value of IdentifiedRouterActionOf<Screen> instead, and benefit from the same terse API as the example above.
If the flow of screens needs to change, the change can be made easily in one place. The screen views and reducers (along with their state and action types) no longer need to have any knowledge of any other screens in the navigation flow - they can simply send an action and leave the coordinator to decide whether a new view should be pushed or presented - which makes it easy to re-use them in different contexts, and helps separate screen responsibilities from navigation responsibilities.
This library uses FlowStacks for hoisting navigation state out of individual screens. FlowStacks can also be used in SwiftUI projects that do not use the Composable Architecture.
There has been an API change from v0.8 to v0.9, to bring the library's APIs more in-line with the Composable Architecture, including the use of case paths. If you're migrating to these new APIs please see the migration docs.
v0.12 introduced a requirement that the screen reducer's state conform to Hashable: see the migration docs.