DEV Community

Manychat Engineering for Manychat

Posted on • Originally published at Medium on

How we solved navigation in a modular SwiftUI app with TCA

We looked for a navigation pattern that fit modular TCA apps. We didn’t find one, so we built our own.

In a SwiftUI app, navigation is one of the first things that breaks once the project stops being simple. NavigationStack and a few @State booleans work well enough in small projects. But when an app goes modular, features move into separate Swift packages, flows need to be reused, deep links show up, and someone eventually asks how navigation logic gets tested. That’s when the simple patterns stop being enough.

I’m Evgeny Serdyukov, iOS Tech Lead at Manyсhat, and this is the story of how we came up with a SwiftUI navigation architecture that actually works for our modular app, built around TCA.

Navigation challenges in a modular app

In a modular app, features are isolated by design. A feature doesn’t know what screen comes before it or after it — and it shouldn’t. But navigation still has to connect everything somehow. How do you do that without features depending on each other?

When we committed to SwiftUI navigation, we quickly realized the examples wouldn’t take us very far. Neither Apple’s documentation nor most community articles cover navigation in a large modular app. SwiftUI gives you all the navigation primitives you need — stacks, pushes, pops, and sheets — but offers little guidance on how those pieces should fit together in a modular architecture.

The next problem appears when a feature becomes a flow. Imagine a multi-step feature (sub-flows) such as checkout with choosing delivery address, payment method, order confirmation, and payment success screen. Internally, it wants its own navigation stack.

Unfortunately, SwiftUI doesn’t allow nesting a NavigationStack inside another NavigationStack. The usual workaround is to present such flows as sheets, giving them a separate navigation context. That works until design requirements get involved.

Our designers wanted the same flow to be reusable in different contexts: sometimes pushed directly into the current navigation stack, sometimes presented as a sheet. The implementation shouldn’t care which presentation style was chosen.

TCA adds another layer of complexity. In TCA, navigation isn’t owned by SwiftUI — it’s part of the feature’s state and managed by reducers. In a modular app, every complex feature has its own reducer, and naturally wants to own its own stack. But that runs straight into SwiftUI’s single-stack limitation.

By this point, we had a fairly concrete set of requirements.

# Requirement Why
R1 Inline push instead of a modal Per-screen swipe-back, host navigation bar stays visible.
R2 One Feature per sub-flow Three-step Checkout is one reducer, not three.
R3 Host doesn't know sub-flow internals Adding a step inside Checkout mustn't touch the host.
R4 Sub-flow doesn't know its host Same Checkout binary plugs into any host.

To explain the solution, I’ll use a simplified checkout flow: Home → Checkout → Payment → Receipt. The full example is on GitHub — a small but complete app with real features, deep links, modal presentations, and tests

Alternatives considered

  1. Canonical TCA StackState TCA has a built-in navigation system. The standard approach is to declare a StackState inside the host reducer — a typed list of everything that can be pushed onto the stack:
@Reducer struct ShopFlow {
 struct State { var path = StackState<Path.State>() }
 @Reducer enum Path {
     case checkout(CheckoutRootFeature)
     case payment(PaymentFeature)
     case receipt(ReceiptFeature)
 }
}
Enter fullscreen mode Exit fullscreen mode

Advancing the sub-flow is straightforward:

state.path.append(.payment(.init()))
Enter fullscreen mode Exit fullscreen mode

At first glance, this looks exactly like what we need.

The problem is that Path lives in the host — ShopFlow. Then the host must enumerate every screen that can ever appear on the stack, including the internal screens of nested sub-flows. Add a step inside Checkout, and you’re editing ShopFlow.

A three-step sub-flow becomes three separate reducers with delegate handoffs between them. The host knows every internal transition and every screen.

For us, that broke the purpose of modularity.

2. TCACoordinators (johnpatrickmorgan)

TCACoordinators brings the Coordinator pattern to TCA and is probably the most commonly suggested solution for complex navigation. It offers two ways to handle sub-flows

Option A: present sub-flow as sheet or full-screen covers. This keeps sub-flows isolated, but they can no longer participate in the parent’s navigation stack. Users can’t navigate through the sub-flow using the same push-based experience, and swipe-back behavior changes.

For us, that violated the requirement that a flow must be reusable both as a push and as a sheet.

Option B: flatten steps into the parent’s screen enum.

This removes the sheet limitation, but puts us back in the same situation as the canonical StackState approach: the parent coordinator must enumerate every screen from every child flow.

The modularity problem returns.

3. Global AppPath enum

Another common approach is to define a single navigation enum at the application root.

To compile, Hub module must depend on EVERY feature in the app at once:

public enum AppPath {
case checkout(CheckoutFeature)
case payment(PaymentFeature)
case receipt(ReceiptFeature)
all other navigation path cases
}
Any host can append any case.
Enter fullscreen mode Exit fullscreen mode

This feels flexible at first, but the flexibility is an illusion. The enum must import every feature, and every feature depends on the enum — the dependency arrows stop pointing one way and the module boundary collapses. Every new screen edits the same file, turning it into a chokepoint for merge conflicts and full-app rebuilds. Worse, ownership leaks upward: the knowledge that payment leads to receipt now lives at the root, not inside checkout. Different name, same shape — this is the god router we set out to avoid.

Alternative R1 inline push R2 single Feature R3 host-blind R4 sub-flow blind
Canonical StackState<Path>
TCACoordinators (sheet)
TCACoordinators (flattened)
Global AppPath

We explored six to eight variations in total. These three represent the main architectural archetypes. Each solved part of the problem, but every one came with a tradeoff we weren’t willing to accept. So we built our own.

The solution: the flow pattern

We didn’t invent this from scratch. The foundation is the Coordinator pattern, originally described by Soroush Khanlou in 2015: a dedicated object owns navigation logic while features emit events.

We call it a Flow rather than a Coordinator for two reasons.

First, to avoid confusion with TCACoordinators, which implements a different navigation model.

Second, a Flow is more than a classic coordinator. It’s a full TCA reducer that owns the child state and can mutate it directly. A traditional Coordinator decides which screen is shown. A Flow also decides what data that screen starts with and can update that data at any point through the normal TCA state tree.

The architecture is built on three primitives:

  1. Shared  — a reference-semantic, lock-protected shared value provided by swift-sharing. The host flow owns one instance and passes it down to child features.
  2. Path enums  — typed hashable values pushed into the NavigationStack path. Each feature declares its own.
  3. .navigationStackContext  — a custom TCA reducer modifier that handles push, cleanup-on-pop, and effect cancellation in one place.

The key idea is simple: navigation is a shared state, not imperative routing. A feature navigates by mutating its own state; the shared stack reflects that change, and .navigationStackContext keeps the two in sync — in both directions — automatically. No feature ever knows the whole map; it only owns its own slice of it.

Before diving into the primitives, it helps to see where everything lives. Here’s how this maps to an actual project structure:

App/
Packages/
├── ThirdParty/ ← TCA wrapper
├── Core/ ← NavigationState, navigationStackContext DSL
├── Features/ ← Isolated feature reducers + views
└── Flows/ ← Navigation composition layer
Enter fullscreen mode Exit fullscreen mode

Dependency arrows flow in one direction:

App → Flows → Features → Core → ThirdParty.

Features don’t import Flows. Flows don’t know how features are presented inside themselves.

Here is how flows (navigation layer) interact with features:

Solid arrows represent state ownership: parent reducers own child state and pass down the shared navigation stack.

Dashed arrows represent delegate actions: child features report meaningful events, and the parent decides what to do next.

Features never talk to each other directly. Everything goes through the Flow above them.

NavigationState

SwiftUI’s built-in NavigationPath is opaque. You can push and pop values, but you can’t inspect what’s currently on the stack. Real navigation logic often needs that visibility: to pop to an anchor, check whether a screen is already pushed, or validate deep-link state.

NavigationState wraps NavigationPath with a parallel [AnyHashable] array, making the stack fully inspectable:

public struct NavigationState: Equatable, @unchecked Sendable {
 private var _path = NavigationPath()
 public private(set) var items: [AnyHashable] = []

 public var path: NavigationPath {
     get { _path }
     set {
         // Called by SwiftUI on back-swipe — trim items to match
         let diff = _path.count - newValue.count
         if diff > 0 { items.removeLast(min(diff, items.count)) }
         _path = newValue
     }
 }

 public mutating func append<V: Hashable & Sendable>(_ value: V) {
     _path.append(value)
     items.append(AnyHashable(value))
 }

 /// Pop everything after the most recent occurrence of `value`; anchor stays.
 public mutating func popTo<V: Hashable & Sendable>(_ value: V) { ... }

 /// Pop the anchor and everything after it.
 public mutating func popPast<V: Hashable & Sendable>(_ value: V) { ... }
}
Enter fullscreen mode Exit fullscreen mode

The implementation maintains a single invariant:

path.count == items.count
Enter fullscreen mode Exit fullscreen mode

The path setter accepts only shorter writes — that’s SwiftUI signaling a back-swipe. Everything else goes through append.

Typed path enums

Every feature that participates in navigation declares its own path enum:

public enum CheckoutPath: Hashable, Sendable {
 case checkout
 case payment
 case receipt
}
Enter fullscreen mode Exit fullscreen mode

This enum is the feature’s public navigation contract.

The host Flow knows that CheckoutPath exists, and nothing more.

Internal screens are represented as enum cases. Adding a new screen to the checkout flow means adding a new case and updating the destinations inside the same feature package. The host remains unchanged.

The navigationStackContext

This is the piece that ties everything together and makes everything declarative. You declare your navigable children once:

// Inside CheckoutFeature.body:
.navigationStackContext(nav: \.$nav, pathAction: \.pathDidChange) { context in
 context.ifLet(\.payment, action: \.payment, path: CheckoutPath.payment) {
     PaymentFeature()
 }
 context.ifLet(\.receipt, action: \.receipt, path: CheckoutPath.receipt) {
     ReceiptFeature()
 }
}
Enter fullscreen mode Exit fullscreen mode

What this one modifier buys you:

1. Auto-push. When state.payment changes from nil to a value, the modifier automatically appends CheckoutPath.payment to the shared navigation stack — nav.items.

No manual navigation mutations:

state.$nav.withLock {
$0.append(.payment)
}
Enter fullscreen mode Exit fullscreen mode

2. Cleanup on pop. When the user swipes back, SwiftUI emits .pathDidChange(newPath). The modifier checks whether CheckoutPath.payment is still present in the navigation stack. If not, it sets state.payment = nil. TCA’s ifLet then automatically cancels any in-flight payment effects.

3. Programmatic pop. Calling state.$nav.withLock { $0.popPast(.payment) } updates the navigation stack. The modifier observes the change and triggers the same cleanup flow as a user-initiated back swipe.

The result is a clear separation of responsibilities: reducers manage state, while navigationStackContext manages navigation synchronization and cleanup.

Inside navigationStackContext

The DSL is built from two lower-level primitives composed together:

  1. ifLetNavigated. It extends TCA’s standard .ifLet with navigation awareness:
func ifLetNavigated<ChildState, ChildAction, Path, Child>( _ 
child: WritableKeyPath<State, ChildState?>,
 action childAction: CaseKeyPath<Action, ChildAction>,
 nav: KeyPath<State, Shared<NavigationState>>,
 path: Path,
 syncOn syncAction: CaseKeyPath<Action, NavigationPath>,
 relayTo childSyncAction: CaseKeyPath<ChildAction, NavigationPath>? = nil,
 @ReducerBuilder<ChildState, ChildAction> then childReducer: () -> Child
) -> some Reducer<State, Action>
Enter fullscreen mode Exit fullscreen mode

It keeps the child state and navigation state synchronized.

Specifically, it:

  • Watches the syncAction (path changes). If the child’s path tag disappears from nav.items, it sets the child state back to_ nil_.
  • Watches state transitions from nil to a value and automatically pushes the corresponding path tag to nav.items.
  • Optionally forwards navigation changes to the child via childSyncAction. This is the relayTo mechanism for nested sub-flows.

2. observingNavChanges. Whenever the navigation stack shrinks — whether because of a back swipe or a programmatic pop — it re-dispatches pathAction:

func observingNavChanges(
 nav: KeyPath<State, Shared<NavigationState>>,
 pathAction: CaseKeyPath<Action, NavigationPath>
) -> some Reducer<State, Action>
Enter fullscreen mode Exit fullscreen mode

This creates a single navigation event stream for all navigation mutations. Whether the user swiped back or a reducer called popPast, navigation follows the same cleanup path.

navigationStackContext — a result builder DSL — composes the primitives above.

It collects _NavChild declarations, applies ifLetNavigated to each one, and wraps the result with observingNavChanges:

func navigationStackContext(
 nav: KeyPath<State, Shared<NavigationState>>,
 pathAction: CaseKeyPath<Action, NavigationPath>,
 @_NavigationBuilder _ build: (NavigationStackContext<State, Action>) -> [_NavChild<State, Action>]
) -> some Reducer<State, Action> {
 let context = NavigationStackContext(nav: nav, pathAction: pathAction)
 let children = build(context)
 var result: any Reducer<State, Action> = self
 for child in children {
     result = child.apply(result) // chain ifLetNavigated for each child
 }
 return _AnyReducerBox(base: result)
     .observingNavChanges(nav: nav, pathAction: pathAction)
}
Enter fullscreen mode Exit fullscreen mode

Flows: the composition layer

A Flow is a reducer that owns Shared and coordinates navigation between features. Here’s ShopFlow from the diagram above:

@Reducer
public struct ShopFlow {
 @ObservableState
 public struct State: Equatable, Sendable {
     @Shared(value: NavigationState()) public var nav

     public var checkout: CheckoutFeature.State?
     public var home = ShopHomeFeature.State()
 }

 public enum Action {
     case checkout(CheckoutFeature.Action)
     case home(ShopHomeFeature.Action)
     case openURL(URL)
     case pathDidChange(NavigationPath)
 }

 public var body: some ReducerOf<Self> {
     Scope(state: \.home, action: \.home) { ShopHomeFeature() }

     Reduce { state, action in
         switch action {
         case let .home(.delegate(.buyButtonTapped(productID))):
             // Just assign state — navigationStackContext auto-pushes
             state.checkout = CheckoutFeature.State(
                 nav: state.$nav,
                 productID: productID
             )
             return .none

         case .checkout(.delegate(.finished)):
             state.$nav.withLock { $0.popPast(CheckoutPath.checkout) }
             return .none

         case let .openURL(url):
             guard let route = ShopDeepLinkParser.parse(url) else { return .none }
             // ...
             return .none

         case .checkout, .home, .pathDidChange:
             return .none
         }
     }
     .navigationStackContext(nav: \.$nav, pathAction: \.pathDidChange) { context in
         context.ifLet(
             \.checkout,
              action: \.checkout,
              path: CheckoutPath.checkout,
              relayTo: \.pathDidChange
         ) {
             CheckoutFeature()
         }
     }
 }
}
Enter fullscreen mode Exit fullscreen mode

ShopFlow doesn’t know that Checkout contains a Payment screen. It doesn’t know that Checkout eventually shows a Receipt screen. It doesn’t even know how many steps the flow contains.

The only thing it knows is that CheckoutPath.checkout is the entry point.

When home signals that the user tapped Buy, ShopFlow assigns CheckoutFeature.State and passes down its shared navigation stack. navigationStackContext auto-pushes the first checkout screen.

From that point on, everything inside CheckoutFeature is opaque to the host. The host Flow sees the flow as a single destination, regardless of how many screens live behind it.

The relayTo: .pathDidChange forwards path changes from ShopFlow into CheckoutFeature’s own navigationStackContext, so that Checkout’s internal screens get proper cleanup when the user swipes all the way back.

The view layer

ShopFlowView’s job is simply to connect SwiftUI’s NavigationStack to the Flow’s shared navigation state and register feature destinations. Here’s the root view for our example flow:

public struct ShopFlowView: View {
 @SwiftUI.Bindable var store: StoreOf<ShopFlow>

 public var body: some View {
     NavigationStack(path: $store.nav.path.sending(\.pathDidChange)) {
         ShopHomeView(store: store.scope(state: \.home, action: \.home))
             .checkoutDestinations(store: store.scope(state: \.checkout, action: \.checkout))
     }
 }
}
Enter fullscreen mode Exit fullscreen mode

.sending(.pathDidChange) is the bridge between SwiftUI.NavigationStack and TCA. When the user swipes back, SwiftUI writes a shorter NavigationPath into the binding. .sending converts that into a .pathDidChange action and sends it through the reducer. That’s what triggers navigationStackContext’s cleanup.

checkoutDestinations is a View extension defined inside CheckoutFeature — the feature owns its destination registration:

public extension View {
 func checkoutDestinations(store: StoreOf<CheckoutFeature>?) -> some View {
     navigationDestination(for: CheckoutPath.self) { path in
         if let store {
             switch path {
             case .checkout:
                 CheckoutView(store: store)
             case .payment:
                 if let s = store.scope(state: \.payment, action: \.payment) {
                     PaymentView(store: s)
                 }
             case .receipt:
                 if let s = store.scope(state: \.receipt, action: \.receipt) {
                     ReceiptView(store: s)
                 }
             }
         }
     }
 }
}
Enter fullscreen mode Exit fullscreen mode

The host Flow attaches checkoutDestinations, but it doesn’t know how Checkout routes internally. The routing logic lives inside the feature package.

Adding a new Checkout screen means updating CheckoutPath and checkoutDestinations. Both changes happen inside the CheckoutFeature package. ShopFlowView doesn’t change.

One more thing is worth mentioning. navigationDestination(for:) must be attached to the root view managed by the NavigationStack, not to one of the pushed destination views.

SwiftUI scopes destination registrations to the subtree of the view that declares them. Registering destinations inside a pushed screen can lead to routes that are ignored or resolved inconsistently.

For that reason, all destination registration in this architecture happens at the stack root, while the destination definitions themselves remain owned by the corresponding feature packages.

Deep linking

Deep links are handled entirely in the Flow layer, where navigation state lives:

case let .openURL(url):
 guard let route = ShopDeepLinkParser.parse(url) else { return .none }
 switch route {
 case let .checkout(productID):
     state.checkout = CheckoutFeature.State(nav: state.$nav, productID: productID)
     return .none

 case let .checkoutPayment(productID):
     state.checkout = CheckoutFeature.State(nav: state.$nav, productID: productID)
     return .send(.checkout(.deepLinkToPayment))

 case let .receipt(orderID):
     state.checkout = CheckoutFeature.State(nav: state.$nav, productID: "unknown")
     return .send(.checkout(.deepLinkToReceipt(orderID: orderID)))
 }
Enter fullscreen mode Exit fullscreen mode

The parser itself is a pure function and can be tested independently of the rest of the navigation system:

public enum ShopDeepLinkParser {
 public static func parse(_ url: URL) -> ShopDeepLink? {
     let segments = pathSegments(from: url)
     // shop://checkout/{productID}
     if segments.count == 2, segments[0] == "checkout" {
         return .checkout(productID: segments[1])
     }
     // shop://checkout/{productID}/payment
     if segments.count == 3, segments[0] == "checkout", segments[2] == "payment" {
         return .checkoutPayment(productID: segments[1])
     }
     // https://example.com/receipt/{orderID}
     if segments.count == 2, segments[0] == "receipt" {
         return .receipt(orderID: segments[1])
     }
     return nil
 }
}
Enter fullscreen mode Exit fullscreen mode

Since navigation state is plain data, handling a deep link is no different from any other flow entry. The Flow just builds the right state and sends the actions. The only view-layer piece is forwarding the URL:

struct ContentView: View {
 @State private var store = Store(initialState: ShopFlow.State()) {
     ShopFlow()
 }
 var body: some View {
     ShopFlowView(store: store)
         .onOpenURL { store.send(.openURL($0)) }
 }
}
Enter fullscreen mode Exit fullscreen mode

Everything past that — parsing, building state, pushing the stack — is pure reducer logic. Which is exactly why deep-link tests need no simulator.

Modal presentations

Modals — sheets and alerts — use TCA’s @Presents, and a key principle holds: a presentation lives with the feature that raises it, the host never touches it.

CheckoutFeature owns a promo-code sheet:

@ObservableState
public struct State: Equatable, Sendable {
 @Presents public var promoCode: PromoCodeFeature.State?
 // ...
}
Enter fullscreen mode Exit fullscreen mode

A sheet is presented by assigning state, and dismissed by setting it back to nil:

case .promoCodeButtonTapped:
 state.promoCode = PromoCodeFeature.State()
 return .none

case let .promoCode(.presented(.delegate(.applied(code)))):
 state.appliedPromoCode = code
 state.promoCode = nil // dismiss the sheet
 return .none
Enter fullscreen mode Exit fullscreen mode

PaymentFeature owns the alert. It’s the one that knows a payment failed. The host never sees the alert:

case .failedButtonTapped:
 state.alert = AlertState {
     TextState("Payment failed")
 } actions: {
     ButtonState(action: .retryPayment) { TextState("Try again") }
     ButtonState(role: .cancel, action: .cancelPayment) { TextState("Cancel") }
 } message: {
     TextState("Your payment could not be processed.")
 }
 return .none

case .alert(.presented(.cancelPayment)):
 return .send(.delegate(.cancelled)) // tell the host; it pops the stack
Enter fullscreen mode Exit fullscreen mode

Both are wired with .ifLet, each inside its own feature’s body:

// inside CheckoutFeature
.ifLet(\.$promoCode, action: \.promoCode) { PromoCodeFeature() }

// inside PaymentFeature
.ifLet(\.$alert, action: \.alert)
Enter fullscreen mode Exit fullscreen mode

Push navigation and modal presentations are symmetric: both are optional states that TCA manages via .ifLet. The only difference is whether you route through NavigationStack or a .sheet / .alert modifier. And just like push navigation, a modal stays encapsulated in the feature that owns it — PaymentFeature decides to alert; CheckoutFeature only learns the user cancelled via a delegate action, then pops the stack.

What a feature needs to be a sub-flow

A feature only needs a few things to become a reusable sub-flow that can participate in any host’s navigation stack.

  1. It defines a typed path enum describing the screens it can navigate to: A *Path enum containing Hashable & Sendable cases for the feature’s screens.
  2. It defines its own destination registration : A *Destinations(store:) view extension that registers navigationDestination(for: *Path.self) and maps path cases to views.
  3. It accepts a navigation stack from its host: State.init(nav: Shared), with a default Shared(value: NavigationState()) value so the feature can also run standalone in a sheet.

If the features contain nested navigation, it also:

  • Declares case pathDidChange(NavigationPath) in Action
  • Uses .navigationStackContext in its body.
  • The parent then passes relayTo: .pathDidChange to forward path changes inward.

The feature lives in Features/, not Flows/. It doesn’t own NavigationState, it uses one passed in from outside.

Here’s what that looks like in practice:

@Reducer
public struct CheckoutFeature {
 @ObservableState
 public struct State: Equatable, Sendable {
     @Shared public var nav: NavigationState // shared with host

     public var payment: PaymentFeature.State?
     public var receipt: ReceiptFeature.State?
     public var productID: String
     // ...
 }

 public enum Action {
     case continueButtonTapped
     case deepLinkToPayment
     case pathDidChange(NavigationPath) // receives path changes from host
     case payment(PaymentFeature.Action)
     case receipt(ReceiptFeature.Action)
     case delegate(Delegate)
     // ...
 }
}
Enter fullscreen mode Exit fullscreen mode

The @shared reference means CheckoutFeature and its host share the same NavigationState. When Checkout pushes .payment, the host’s NavigationStack sees the change immediately. The default value in init means the same feature works as a sheet.

Testing

TCA’s TestStore makes navigation logic straightforward to test. The entire stack is inspectable via nav.items:

func test_buyButtonPushesCheckout() async {
 let store = TestStore(initialState: ShopFlow.State()) { ShopFlow() }
 store.exhaustivity = .off

 await store.send(.home(.buyButtonTapped))
 await store.skipReceivedActions()

    XCTAssertEqual(store.state.checkout?.productID, "coffee-grinder")
    XCTAssertTrue(store.state.nav.items.contains { ($0 as? CheckoutPath) == .checkout })
}

func test_backSwipeCleansPaymentButKeepsCheckout() async {
 let store = TestStore(initialState: ShopFlow.State()) { ShopFlow() }
 store.exhaustivity = .off

 await store.send(.home(.buyButtonTapped))
 await store.skipReceivedActions()
 await store.send(.checkout(.continueButtonTapped))

 // Simulate swipe-back to checkout (remove payment from path)
 var path = NavigationPath()
 path.append(CheckoutPath.checkout)
 await store.send(.pathDidChange(path))
 await store.skipReceivedActions()

 XCTAssertNotNil(store.state.checkout) // checkout stays
    XCTAssertNil(store.state.checkout?.payment) // payment cleaned up
 XCTAssertEqual(
     store.state.nav.items.compactMap { $0 as? CheckoutPath },
     [.checkout]
 )
}

func test_deepLinkToPaymentBuildsFeatureAndStack() async {
 let store = TestStore(initialState: ShopFlow.State()) { ShopFlow() }
 store.exhaustivity = .off

 await store.send(.openURL(URL(string: "shop://checkout/espresso-machine/payment")!))
 await store.skipReceivedActions()

    XCTAssertEqual(store.state.checkout?.productID, "espresso-machine")
    XCTAssertNotNil(store.state.checkout?.payment)
 XCTAssertEqual(
     store.state.nav.items.compactMap { $0 as? CheckoutPath },
     [.checkout, .payment]
 )
}

func test_receiptCloseFinishesFlow() async {
 let store = TestStore(initialState: ShopFlow.State()) { ShopFlow() }
 store.exhaustivity = .off

 await store.send(.openURL(URL(string: "https://example.com/receipt/order-42")!))
 await store.skipReceivedActions()
 await store.send(.checkout(.receipt(.closeButtonTapped)))
 await store.skipReceivedActions()

 XCTAssertNil(store.state.checkout)
    XCTAssertTrue(store.state.nav.items.isEmpty)
}
Enter fullscreen mode Exit fullscreen mode

You can test deep links, back-swipes, alert interactions, and multi-screen flows without ever launching a simulator. Navigation logic is just state transformation.

Conclusion

This is a niche solution.

If you’re building a small app or a team of two, the canonical StackState works fine. The complexity here pays off when you have multiple teams working on isolated feature packages, designers who want full flexibility in how sub-flows are presented, and a codebase where build times matter.

The cost is real: roughly 150 lines of custom infrastructure in Core. In return:

  • Features are isolated reducers that emit delegate actions. They don’t know what comes before or after them.
  • Flows own Shared, compose features, and route delegate actions between them.
  • navigationStackContext handles push, cleanup, and effect cancellation automatically.
  • Deep links are just state construction and actions — no different from any other flow entry.
  • Navigation logic is plain state, so tests need no simulator.

Most importantly, sub-flows remain self-contained.

We pushed the same CheckoutFeature binary into three different hosts without touching it once. Our designers get their animations. A change inside any feature doesn’t touch Flows or App — the compiler sees the boundary and rebuilds only what changed.

In practice, incremental builds run in roughly 2 seconds.

Enjoy this kind of problem-solving? Come build cool things with a great team — join Manychat.


Top comments (0)