Overview
Tree-based navigation uses optional and enum state to model navigation. This style allows you to deep-link into any state by constructing deeply nested state and handing it to SwiftUI.
Key tools:
@Presents macro
PresentationAction type
.ifLet(_:action:destination:) reducer operator
Basics
Integrating features for navigation involves two steps: integrating domains and integrating views.
Step 1: Integrate Domains
Add child state and actions to the parent using @Presents and PresentationAction:
@Reducer
struct InventoryFeature {
@ObservableState
struct State : Equatable {
@Presents var addItem: ItemFormFeature.State ?
var items: IdentifiedArrayOf<Item> = []
// ...
}
enum Action {
case addItem (PresentationAction<ItemFormFeature.Action>)
// ...
}
// ...
}
The addItem state is optional. Non-nil means presented, nil means dismissed.
Integrate reducers using .ifLet and add an action to populate child state:
@Reducer
struct InventoryFeature {
@ObservableState
struct State : Equatable { /* ... */ }
enum Action { /* ... */ }
var body: some ReducerOf< Self > {
Reduce { state, action in
switch action {
case . addButtonTapped :
// Populating this state performs the navigation
state. addItem = ItemFormFeature. State ()
return . none
// ...
}
}
. ifLet (\.$addItem, action : \. addItem ) {
ItemFormFeature ()
}
}
}
The key path uses the $ syntax to focus on the @Presents projected value.
Step 2: Integrate Views
Pass a binding of a store to SwiftUI’s view modifiers:
struct InventoryView : View {
@Bindable var store: StoreOf<InventoryFeature>
var body: some View {
List {
// ...
}
. sheet (
item : $store. scope ( state : \. addItem , action : \. addItem )
) { store in
ItemFormView ( store : store)
}
}
}
Use SwiftUI’s @Bindable to produce a binding to a store, then scope it using .scope(state:action:).
This pattern works with all SwiftUI navigation modifiers:
sheet(item:)
popover(item:)
fullScreenCover(item:)
navigationDestination(item:)
And more
Enum State
Modeling multiple destinations with multiple optionals creates invalid states:
@ObservableState
struct State {
@Presents var detailItem: DetailFeature.State ?
@Presents var editItem: EditFeature.State ?
@Presents var addItem: AddFeature.State ?
// Multiple could be non-nil simultaneously! ⚠️
}
Invalid states increase exponentially:
3 optionals → 4 invalid states
4 optionals → 11 invalid states
5 optionals → 26 invalid states
Solution: Use an Enum
Model multiple destinations as a single enum:
enum State {
case addItem (AddFeature.State)
case detailItem (DetailFeature.State)
case editItem (EditFeature.State)
// ...
}
This provides compile-time proof that only one destination is active at a time.
Implementation
Define a Destination Reducer
Use the @Reducer macro on an enum to auto-generate the full reducer:
@Reducer
struct InventoryFeature {
// ...
@Reducer
enum Destination {
case addItem (AddFeature)
case detailItem (DetailFeature)
case editItem (EditFeature)
}
}
The @Reducer macro expands this simple enum into a fully composed feature with State and Action types. Use Xcode’s “Expand Macro” to see what’s generated.
Hold a Single Optional State
@Reducer
struct InventoryFeature {
@ObservableState
struct State {
@Presents var destination: Destination.State ?
// ...
}
enum Action {
case destination (PresentationAction<Destination.Action>)
// ...
}
// ...
}
Integrate with .ifLet
@Reducer
struct InventoryFeature {
// ...
var body: some ReducerOf< Self > {
Reduce { state, action in
// ...
}
. ifLet (\.$destination, action : \. destination )
}
}
Present Features by Setting Enum Cases
case . addButtonTapped :
state. destination = . addItem (AddFeature. State ())
return . none
Scope Views to Specific Cases
struct InventoryView : View {
@Bindable var store: StoreOf<InventoryFeature>
var body: some View {
List {
// ...
}
. sheet (
item : $store. scope (
state : \. destination ? . addItem ,
action : \. destination . addItem
)
) { store in
AddFeatureView ( store : store)
}
. popover (
item : $store. scope (
state : \. destination ? . editItem ,
action : \. destination . editItem
)
) { store in
EditFeatureView ( store : store)
}
. navigationDestination (
item : $store. scope (
state : \. destination ? . detailItem ,
action : \. destination . detailItem
)
) { store in
DetailFeatureView ( store : store)
}
}
}
API Unification
One of tree-based navigation’s best features is API unification. Regardless of navigation type (drill-down, sheet, alert, etc.):
Domain integration uses the single .ifLet operator
View integration provides a store focused on presentation state/action
Example showing multiple navigation types unified:
. sheet (
item : $store. scope ( state : \. addItem , action : \. addItem )
) { store in
AddFeatureView ( store : store)
}
. popover (
item : $store. scope ( state : \. editItem , action : \. editItem )
) { store in
EditFeatureView ( store : store)
}
. navigationDestination (
item : $store. scope ( state : \. detailItem , action : \. detailItem )
) { store in
DetailFeatureView ( store : store)
}
. alert (
$store. scope ( state : \. alert , action : \. alert )
)
. confirmationDialog (
$store. scope ( state : \. confirmationDialog , action : \. confirmationDialog )
)
Backwards Compatibility
For iOS <16, macOS <13, tvOS <16, watchOS <9, use this NavigationLink helper:
@available ( iOS , introduced : 13 , deprecated : 16 )
@available ( macOS , introduced : 10.15 , deprecated : 13 )
@available ( tvOS , introduced : 13 , deprecated : 16 )
@available ( watchOS , introduced : 6 , deprecated : 9 )
extension NavigationLink {
public init < D , C : View >(
item : Binding<D ? >,
onNavigate : @escaping (_ isActive: Bool ) -> Void ,
@ ViewBuilder destination : (D) -> C,
@ ViewBuilder label : () -> Label
) where Destination == C ? {
self . init (
destination : item. wrappedValue . map (destination),
isActive : Binding (
get : { item. wrappedValue != nil },
set : { isActive, transaction in
onNavigate (isActive)
if ! isActive {
item. transaction (transaction). wrappedValue = nil
}
}
),
label : label
)
}
}
Integration
Parent features get instant access to everything in child features. Detect child actions by destructuring:
case . destination (. presented (. editItem (. saveButtonTapped ))) :
guard case let . editItem (editItemState) = state.destination
else { return . none }
state. destination = nil
return . run { _ in
self . database . save (editItemState. item )
}
Dismissal
Dismiss by nil-ing out the state:
case . closeButtonTapped :
state. destination = nil
return . none
Self-Dismissal from Child
Use the @Dependency(\.dismiss) to allow children to dismiss themselves:
@Reducer
struct Feature {
@ObservableState
struct State { /* ... */ }
enum Action {
case closeButtonTapped
// ...
}
@Dependency (\. dismiss ) var dismiss
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case . closeButtonTapped :
return . run { _ in await self . dismiss () }
}
}
}
}
Important: The DismissEffect is async and must be called from .run. Never send actions after calling dismiss():return . run { send in
await self . dismiss ()
await send (. tick ) // ⚠️ Don't do this!
}
SwiftUI’s @Environment(\.dismiss) and TCA’s @Dependency(\.dismiss) are different types with different purposes:
SwiftUI’s: Use in views only
TCA’s: Use in reducers only
Testing
Properly modeled navigation makes testing straightforward. Non-exhaustive testing is especially useful for navigation.
Example: Testing Dismissal
Counter feature that dismisses when count ≥ 5:
@Reducer
struct CounterFeature {
@ObservableState
struct State : Equatable {
var count = 0
}
enum Action {
case decrementButtonTapped
case incrementButtonTapped
}
@Dependency (\. dismiss ) var dismiss
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case . decrementButtonTapped :
state. count -= 1
return . none
case . incrementButtonTapped :
state. count += 1
return state. count >= 5
? . run { _ in await self . dismiss () }
: . none
}
}
}
}
Parent feature:
@Reducer
struct Feature {
@ObservableState
struct State : Equatable {
@Presents var counter: CounterFeature.State ?
}
enum Action {
case counter (PresentationAction<CounterFeature.Action>)
}
var body: some Reducer<State, Action> {
Reduce { state, action in
// Logic and behavior for core feature.
}
. ifLet (\.$counter, action : \. counter ) {
CounterFeature ()
}
}
}
Exhaustive Test
@Test
func dismissal () {
let store = TestStore (
initialState : Feature. State (
counter : CounterFeature. State ( count : 3 )
)
) {
CounterFeature ()
}
await store. send (\. counter . incrementButtonTapped ) {
$0 . counter ? . count = 4
}
await store. send (\. counter . incrementButtonTapped ) {
$0 . counter ? . count = 5
}
await store. receive (\. counter . dismiss ) {
$0 . counter = nil
}
}
Non-Exhaustive Test
Turn off exhaustivity for high-level assertions:
@Test
func dismissal () {
let store = TestStore (
initialState : Feature. State (
counter : CounterFeature. State ( count : 3 )
)
) {
CounterFeature ()
}
store. exhaustivity = . off
await store. send (\. counter . incrementButtonTapped )
await store. send (\. counter . incrementButtonTapped )
await store. receive (\. counter . dismiss )
}
Non-exhaustive tests are more concise and resilient to changes you don’t care about.
Testing with Enum State
When using enum destinations, chain into the specific case:
await store. send (\. destination . counter . incrementButtonTapped ) {
$0 . destination ? . counter ? . count = 4
}
Navigation Overview Learn about navigation concepts and patterns
Stack-based Navigation Learn about navigation with collections