Skip to main content

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:
The addItem state is optional. Non-nil means presented, nil means dismissed.
Integrate reducers using .ifLet and add an action to populate child state:
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:
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:
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:
This provides compile-time proof that only one destination is active at a time.

Implementation

  1. Define a Destination Reducer
Use the @Reducer macro on an enum to auto-generate the full reducer:
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.
  1. Hold a Single Optional State
  1. Integrate with .ifLet
  1. Present Features by Setting Enum Cases
  1. Scope Views to Specific Cases

API Unification

One of tree-based navigation’s best features is API unification. Regardless of navigation type (drill-down, sheet, alert, etc.):
  1. Domain integration uses the single .ifLet operator
  2. View integration provides a store focused on presentation state/action
Example showing multiple navigation types unified:

Backwards Compatibility

For iOS <16, macOS <13, tvOS <16, watchOS <9, use this NavigationLink helper:

Integration

Parent features get instant access to everything in child features. Detect child actions by destructuring:

Dismissal

Dismiss by nil-ing out the state:

Self-Dismissal from Child

Use the @Dependency(\.dismiss) to allow children to dismiss themselves:
Important: The DismissEffect is async and must be called from .run. Never send actions after calling dismiss():
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:
Parent feature:

Exhaustive Test

Non-Exhaustive Test

Turn off exhaustivity for high-level assertions:
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:

Navigation Overview

Learn about navigation concepts and patterns

Stack-based Navigation

Learn about navigation with collections