SwiftTCAArchitectureSwiftUIiOS

TCA from scratch (part 2): composition and state-driven navigation

In part 1 we built an isolated search feature: state, cancellable effects, an injected dependency and a test. It works, but it isn’t an app. An app is the search screen that opens a detail, that opens an editor, that sometimes shows a modal sheet and other times a three-level navigation stack.

And here a familiar problem shows up: navigation tends to end up living in the view: a @State private var showingSheet = false here, a NavigationLink there, and suddenly you can’t reproduce an app state without tapping the screen with your finger.

In TCA, navigation is state too. And if it’s in the state, it can be composed, serialized, restored and tested.

Let’s extend things. Our search feature now opens a repository detail, and that detail can open a sheet for editing notes.

Composition: one feature inside another

The simplest case: a feature always contains another. Think of a settings screen that includes an account section.

@Reducer
struct SettingsFeature {
    @ObservableState
    struct State: Equatable {
        var account = AccountFeature.State()
        var darkMode = false
    }

    enum Action {
        case account(AccountFeature.Action)
        case darkModeChanged(Bool)
    }

    var body: some ReducerOf<Self> {
        Scope(state: \.account, action: \.account) {
            AccountFeature()
        }

        Reduce { state, action in
            switch action {
            case .account:
                return .none
            case let .darkModeChanged(on):
                state.darkMode = on
                return .none
            }
        }
    }
}

Scope connects a slice of the parent’s state to the child’s reducer. From then on the child’s actions arrive wrapped in .account(...), and the parent can react to them if it cares.

This second point is the one people use the least, and the one that has helped me the most. The parent can listen to child actions to coordinate:

case .account(.loggedOut):
    state.darkMode = false
    return .run { send in
        await clearCache()
    }

The child doesn’t know the parent exists; the parent decides what it means for the child to have logged out. It’s the same problem you’d normally solve with a delegate or a NotificationCenter post, but without intermediate objects to register and tear down.

In the view, scope does the equivalent:

struct SettingsView: View {
    @Bindable var store: StoreOf<SettingsFeature>

    var body: some View {
        Form {
            AccountView(store: store.scope(state: \.account, action: \.account))
            Toggle("Dark mode", isOn: $store.darkMode.sending(\.darkModeChanged))
        }
    }
}

Lists: forEach over identified state

Our search returns a list. If each row had its own logic (a favorite button with its own network call, say), each row needs its own feature.

That’s what IdentifiedArrayOf and the forEach operator are for:

@ObservableState
struct State: Equatable {
    var query = ""
    var rows: IdentifiedArrayOf<RowFeature.State> = []
}

enum Action {
    case queryChanged(String)
    case rows(IdentifiedActionOf<RowFeature>)
}

var body: some ReducerOf<Self> {
    Reduce { state, action in
        // ...
    }
    .forEach(\.rows, action: \.rows) {
        RowFeature()
    }
}

Why IdentifiedArrayOf and not [RowFeature.State]? Because with a plain array, actions would travel by index, and indices shift. If row 3 fires an effect and meanwhile row 1 gets deleted, that effect would end up applying to a different row. With IDs that can’t happen: the action targets a specific element, and if that element is gone, the action is simply discarded.

It’s a classic async-list bug, and here the data type prevents it.

Now the part that changes how you think. Our detail is presented from the search screen, and from the detail we open an edit sheet.

First we declare every possible destination as an enum of reducers:

@Reducer
enum Destination {
    case detail(DetailFeature)
    case editor(EditorFeature)
}

That macro on an enum generates the matching State and Action, each case wrapping the child’s. In the parent:

@ObservableState
struct State: Equatable {
    var query = ""
    var results: [Repository] = []
    @Presents var destination: Destination.State?
}

enum Action {
    case queryChanged(String)
    case resultsReceived([Repository])
    case repositoryTapped(Repository)
    case destination(PresentationAction<Destination.Action>)
}

var body: some ReducerOf<Self> {
    Reduce { state, action in
        switch action {
        case let .repositoryTapped(repo):
            state.destination = .detail(DetailFeature.State(repo: repo))
            return .none
        // ...
        }
    }
    .ifLet(\.$destination, action: \.destination)
}

Notice what just happened: presenting a screen is assigning a value to a property. No more isPresented, no more boolean flags drifting out of sync with each other.

And because destination is an optional enum, the compiler guarantees you can’t accidentally have two screens presented at once. With three loose @State private var showingX, that invalid state is representable, and sooner or later somebody produces it.

The view:

struct SearchView: View {
    @Bindable var store: StoreOf<SearchFeature>

    var body: some View {
        List(store.results) { repo in
            Button(repo.name) { store.send(.repositoryTapped(repo)) }
        }
        .navigationDestination(
            item: $store.scope(\.destination, action: \.destination).detail
        ) { store in
            DetailView(store: store)
        }
        .sheet(
            item: $store.scope(\.destination, action: \.destination).editor
        ) { store in
            EditorView(store: store)
        }
    }
}

The same destination drives both a push and a modal sheet, depending on the enum case. The API is identical for sheet, popover, navigationDestination and alert, which is one of the things you appreciate most on a large project: you learn one pattern and it covers all four cases.

ifLet isn’t decoration

.ifLet(\.$destination, action: \.destination) does three things people forget to do by hand:

  1. Runs the child reducer only while something is presented.
  2. Automatically cancels every child effect when the screen is dismissed. Without it, a network request started in the detail would stay alive after you close it.
  3. Gives the child the ability to dismiss itself via @Dependency(\.dismiss), without knowing the parent.

Point 2 is the kind of leak that, in a hand-rolled app, you discover three months later when something writes into state that shouldn’t exist anymore. It happened to me, and tracking it down was no fun.

Deep stacks: StackState

@Presents covers one-off presentations. For a NavigationStack with arbitrary depth (detail → detail → detail) the model is different, though very similar:

@Reducer
enum Path {
    case detail(DetailFeature)
    case editor(EditorFeature)
}

@ObservableState
struct State: Equatable {
    var path = StackState<Path.State>()
}

enum Action {
    case path(StackActionOf<Path>)
}

var body: some ReducerOf<Self> {
    Reduce { state, action in
        // ...
    }
    .forEach(\.path, action: \.path)
}

And the view:

NavigationStack(path: $store.scope(\.path, action: \.path)) {
    SearchView(store: store)
} destination: { store in
    switch store.case {
    case let .detail(store): DetailView(store: store)
    case let .editor(store): EditorView(store: store)
    }
}

Pushing a screen from the reducer is state.path.append(.detail(...)). Going back two levels is dropping two elements from the stack. The entire navigation state is a value you can print, save and compare.

When do you use which? If the screen is presented over the current one and closes back exactly where you were, @Presents. If the user can keep drilling deeper, StackState.

Testing a navigation flow

Here’s the payoff. A flow that in a normal app can only be verified by tapping the screen becomes a test:

@Test
func openDetailAndDismiss() async {
    let repo = Repository(id: 1, name: "swift", description: "The language")

    let store = await TestStore(
        initialState: SearchFeature.State(results: [repo])
    ) {
        SearchFeature()
    }

    await store.send(.repositoryTapped(repo)) {
        $0.destination = .detail(DetailFeature.State(repo: repo))
    }

    await store.send(.destination(.dismiss)) {
        $0.destination = nil
    }
}

No simulator, no XCUITest, no flaky waits. It’s a function that runs in milliseconds and verifies that tapping a row opens exactly the right detail.

And if the child fires an effect, TestStore requires you to verify that too, so you can’t dismiss the screen and leave work pending without the test telling you.

Where it hurts

It would be dishonest not to say it: composition in TCA has a concrete cost.

  • Types get big: IdentifiedActionOf<RowFeature>, PresentationAction<Destination.Action>, StackActionOf<Path>. When something doesn’t compile, the error message can be twenty lines long.
  • There are more pieces to wire. A forgotten Scope makes the child simply not react, with no error at all, and it’s hard to spot the first time.
  • Compile times go up. Every macro generates code, and in modules with many features you feel it.

In exchange: the navigation state of the entire app fits in a single value, it can’t become inconsistent, and it’s testable without opening the simulator. In a two-screen app that doesn’t pay off. In a twenty-screen one, with deep links and state restoration, it changes the game.

What’s next

We know how to build features and compose them. What’s missing is the problem that shows up when the app really grows: state that several features need at once, like the authenticated user, favorites or settings. Duplicating it leads to inconsistency, and passing it as a parameter through five levels is unbearable.

In part 3 we look at @Shared, persistence, and how TCA handles global state without turning into a singleton in disguise. After that comes testing in depth and the comparison against MVVM.

Sources: Tree-based navigation (docs) · Stack-based navigation (docs)