Overview
Stack-based navigation models navigation using collections of state. This allows deep-linking by constructing flat collections of data and supports complex, recursive navigation paths. Key tools:StackStatecollection typeStackActiontype.forEach(_:action:)reducer operator- Custom
NavigationStackinitializer
Basics
Integrating features into a navigation stack involves two main steps: integrating domains and constructing theNavigationStack view.
Step 1: Define Path Reducer
Create a reducer holding all features that can be pushed onto the stack:The
Path reducer is identical to the Destination reducer used in tree-based navigation with enums.Step 2: Add StackState and StackAction
Hold navigation stack state and actions in the root feature:Step 3: Integrate with .forEach
Use .forEach to integrate path features with the parent:
Step 4: Build NavigationStack View
Use the customNavigationStack initializer that takes a store binding:
Step 5: Handle Each Path Case
Usestore.case to destructure each case and return the appropriate view:
Pushing Features onto the Stack
There are two primary ways to push features onto the stack:1. Using NavigationLink (Simple)
Use the customNavigationLink initializer with full state:
StackAction.push(id:state:) action is sent, appending to the stack.
2. Using Button + Action (Modular)
Send an action from the child feature:This approach maintains modularity since the child feature doesn’t need to know about
Path.State.Integration
Parent features have instant access to everything in the stack. Detect child actions by destructuring:When destructuring
StackAction.element(id:action:), you get both:- The action that happened
- The ID of the element in the stack
StackState automatically manages IDs for every feature.Dismissal
Dismiss features by mutatingStackState:
popLast()- Remove last elementpop(from:)- Remove from specific ID- And more collection methods
Self-Dismissal from Child
Use@Dependency(\.dismiss) to allow children to dismiss themselves:
dismiss() is called, a StackAction.popFrom(id:) action is sent to remove the feature from the stack.
SwiftUI’s
@Environment(\.dismiss) and TCA’s @Dependency(\.dismiss) are different types:- SwiftUI’s: Use in views only
- TCA’s: Use in reducers only
Testing
Using TCA’s tools makes testing navigation stacks straightforward. Non-exhaustive testing is especially useful.Example: Testing Dismissal
Counter feature that dismisses when count ≥ 5:Test with IDs
Construct a test store with a counter already on the stack:StackState automatically manages IDs. In tests, IDs are integers starting at 0 and incrementing for each feature pushed.Two Ways to Assert State Changes
Option 1: UsingXCTModify
XCTModify takes an inout enum, extracts the case payload, lets you mutate it, and embeds it back.
Option 2: Using Double Subscript
Complete Test
Receiving Child Actions
To assert a specific child action is received, use subscript on the case key path:Non-Exhaustive Test
Turn off exhaustivity for high-level assertions:StackState vs NavigationPath
SwiftUI providesNavigationPath, so why use StackState?
NavigationPath
Pros:- Type-erased list of any
Hashabledata - Maximal feature decoupling
- Limited API: only
append,removeLast, andcount - Cannot insert/remove from middle
- Cannot iterate over elements
- Hard to analyze stack contents
StackState
Pros:- Conforms to
Collection,RandomAccessCollection,RangeReplaceableCollection - Access to many collection manipulation methods
- Can iterate, insert, remove anywhere
- Automatic stable identifier management
- Data doesn’t need to be
Hashable
- Fully statically typed (less flexible than type erasure)
StackState balances runtime flexibility with static, compile-time guarantees—perfect for TCA navigation.UIKit
TCA providesNavigationStackController for state-driven UINavigationController:
Related
Navigation Overview
Learn about navigation concepts and patterns
Tree-based Navigation
Learn about navigation with optionals and enums