SwiftTCANavigationSwiftUIiOS

Pokédex in TCA (part 3): the detail and navigation as state

With part 2 we have a solid list: paginated, with visible errors and retry. Now we open the detail. The list already carries id, name, types and sprite, but the detail shows more: stats, abilities, height and weight. That gets requested separately from PokeAPI’s /pokemon/{id}.

The real focus of the post isn’t that second request, it’s how navigation is modeled in TCA. In the PokeTracker series we used NavigationLink(value:) with navigationDestination(for:), which is idiomatic in plain SwiftUI. In TCA navigation lives in the state, and that changes how we think about the flow.

Why navigation as state

A NavigationLink decides to navigate at tap time. It’s fast to write and it works, but it leaves the app’s state scattered: the view knows if the detail is open, the state doesn’t. Consequences:

  • You can’t open the detail from code without tricks.
  • A deep link that wants to land on the detail has to reproduce the tap.
  • A test for “opening the detail” needs the simulator.
  • Restoring the app on the detail after a kill is hard.

TCA proposes that the detail be a value in the parent state. If it’s present, it’s shown; if it’s nil, it’s closed. Opening is assigning, closing is setting to nil. Everything else comes for free: you can open from code, a deep link just has to create the right state, a test verifies it without touching the screen, and restoration is serializing and deserializing the state.

The destination as an enum

Even though we only have one destination for now (the detail), I model it as an enum. The reason is that apps rarely stay on a single presentable screen, and adding a second one (say, a share sheet) on top of an enum is trivial. On top of a single-type @Presents var detail: PokemonDetailFeature.State?, it forces a refactor.

The modern TCA pattern is:

@Reducer
enum Destination {
    case detail(PokemonDetailFeature)
}

That macro generates Destination.State and Destination.Action with a case per child reducer. In the parent it looks like this:

@Reducer
struct PokemonListFeature {
    @ObservableState
    struct State: Equatable {
        var loadState: LoadState<[Pokemon]> = .idle
        var isLoadingMore = false
        var canLoadMore = true
        @Presents var destination: Destination.State?
    }

    enum Action {
        // ... part 2 actions ...
        case rowTapped(Pokemon)
        case destination(PresentationAction<Destination.Action>)
    }
}

@Presents marks that state as presentable, and PresentationAction is a wrapper that automatically adds the .dismiss case for closing. In the reducer’s body, ifLet does the real work:

var body: some ReducerOf<Self> {
    Reduce { state, action in
        switch action {
        // ... part 2 cases ...

        case let .rowTapped(pokemon):
            state.destination = .detail(PokemonDetailFeature.State(pokemon: pokemon))
            return .none

        case .destination:
            return .none
        }
    }
    .ifLet(\.$destination, action: \.destination) {
        Destination()
    }
}

That ifLet does three things people forget to do by hand and we don’t talk about again:

  • Runs the child reducer only while something is presented.
  • Automatically cancels every child effect when the user dismisses the screen. If the detail was mid-request and the user goes back, that request cancels itself.
  • Gives the child the ability to dismiss itself via @Dependency(\.dismiss).

Point 2 is what in the MVVM Pokédex we did by hand: canceling the Task in onDisappear. Here the framework handles it.

The DetailFeature

Given the initial state (the Pokemon from the list), the child requests the full detail:

@Reducer
struct PokemonDetailFeature {
    @ObservableState
    struct State: Equatable {
        let pokemon: Pokemon
        var extra: LoadState<PokemonDetail> = .idle
    }

    enum Action {
        case onAppear
        case detailReceived(PokemonDetail)
        case detailFailed(String)
        case closeTapped
    }

    @Dependency(\.pokemonClient) var pokemonClient
    @Dependency(\.dismiss) var dismiss

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .onAppear:
                if case .loaded = state.extra { return .none }
                state.extra = .loading
                let id = state.pokemon.id
                return .run { [pokemonClient] send in
                    let detail = try await pokemonClient.fetchDetail(id)
                    await send(.detailReceived(detail))
                } catch: { error, send in
                    await send(.detailFailed(error.localizedDescription))
                }

            case let .detailReceived(detail):
                state.extra = .loaded(detail)
                return .none

            case let .detailFailed(message):
                state.extra = .failed(message)
                return .none

            case .closeTapped:
                return .run { _ in await self.dismiss() }
            }
        }
    }
}

Two important things.

We reuse the LoadState<PokemonDetail> from part 2. That’s the payoff of having made that enum generic: any feature that loads something from the network uses the same type, and its view knows what to expect.

@Dependency(\.dismiss) var dismiss is the magic that lets the child close itself without knowing the parent. When the child does await self.dismiss(), TCA propagates that up to the parent, which sets its destination to nil. The child never imports PokemonListFeature, and that’s why it could be reused tomorrow from another screen that also presents it.

We add fetchDetail to the client:

struct PokemonClient {
    var fetchList: (_ limit: Int, _ offset: Int) async throws -> [Pokemon]
    var fetchDetail: (_ id: Int) async throws -> PokemonDetail
}

Live and test implementations are filled in the same way as in part 1.

The detail view

struct PokemonDetailView: View {
    @Bindable var store: StoreOf<PokemonDetailFeature>

    var body: some View {
        List {
            Section {
                AsyncImage(url: store.pokemon.spriteURL) { $0.resizable().scaledToFit() }
                    placeholder: { ProgressView() }
                    .frame(height: 160)
            }

            Section("Info") {
                switch store.extra {
                case .idle, .loading:
                    HStack { ProgressView(); Text("Loading detail…") }
                case let .loaded(detail):
                    LabeledContent("Height", value: "\(detail.height) dm")
                    LabeledContent("Weight", value: "\(detail.weight) hg")
                    LabeledContent("Abilities", value: detail.abilities.joined(separator: ", "))
                case let .failed(message):
                    Text(message).foregroundStyle(.red)
                }
            }
        }
        .navigationTitle(store.pokemon.name.capitalized)
        .toolbar {
            ToolbarItem(placement: .cancellationAction) {
                Button("Close") { store.send(.closeTapped) }
            }
        }
        .onAppear { store.send(.onAppear) }
    }
}

Same pattern as part 2: exhaustive switch on the LoadState. The header with the sprite and the name is shown always because that data already came with the list’s Pokemon; only the “Info” section waits for the full detail.

The list, presenting the detail

In the list view all it takes is wiring the NavigationLink (or Button, depending on your style preference) to fire the action, and adding the modifier that presents the destination:

struct PokemonListView: View {
    @Bindable var store: StoreOf<PokemonListFeature>

    var body: some View {
        Group {
            switch store.loadState {
            // ... same as part 2 ...
            case let .loaded(pokemons):
                loadedList(pokemons)
            // ...
            }
        }
        .navigationDestination(
            item: $store.scope(state: \.destination?.detail, action: \.destination.detail)
        ) { detailStore in
            PokemonDetailView(store: detailStore)
        }
        .onAppear { store.send(.onAppear) }
    }

    private func loadedList(_ pokemons: [Pokemon]) -> some View {
        List {
            ForEach(pokemons) { pokemon in
                PokemonRow(pokemon: pokemon)
                    .contentShape(Rectangle())
                    .onTapGesture {
                        store.send(.rowTapped(pokemon))
                    }
                    .onAppear {
                        if pokemon.id == pokemons.last?.id {
                            store.send(.reachedEnd)
                        }
                    }
            }
        }
    }
}

Notice the row isn’t wrapped in a Button: .contentShape(Rectangle()) + .onTapGesture is the pattern that works inside a List. A Button with .buttonStyle(.plain) looks like the natural choice, but List intercepts the tap and the action never reaches the reducer. I confirmed it running the app: until I switched to .onTapGesture, tapping a row didn’t navigate.

The other trick is $store.scope(state: \.destination?.detail, action: \.destination.detail): it asks the store for the destination slot and the .detail case of the enum, and returns a binding SwiftUI understands. If the enum had other cases (.share, .editor), each presentation modifier (sheet, popover, etc.) would point at its case.

One more note about the destination enum: @Reducer enum doesn’t propagate Equatable to Destination.State automatically. If the parent state requires it (our case), add the conformance in an extension:

extension Destination.State: Equatable {}

The @Reducer(state: .equatable) enum syntax is deprecated.

A likely test flow

Here’s where navigation-as-state pays off. In part 6 we’ll do full flow tests, but it’s worth previewing why this is different:

@Test
func openDetailAndDismiss() async {
    let pokemon = Pokemon(id: 1, name: "bulbasaur", spriteURL: nil, types: ["grass"])

    let store = await TestStore(
        initialState: PokemonListFeature.State(loadState: .loaded([pokemon]))
    ) {
        PokemonListFeature()
    }

    await store.send(.rowTapped(pokemon)) {
        $0.destination = .detail(PokemonDetailFeature.State(pokemon: pokemon))
    }

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

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

A note on the image cache

In the detail we show the sprite again, and here the image cache from PokeTracker part 7 pays off: without it, opening the detail makes SwiftUI redownload the image we just showed in the row. The cache is the exact same component as in the MVVM Pokédex; it lives outside the reducer and plugs in without friction.

What we’ve got

  • Navigation is a value in the parent state, present via @Presents + a Destination enum.
  • ifLet wires the child and cancels its effects when dismissed.
  • The child dismisses itself via @Dependency(\.dismiss) without knowing the parent.
  • The detail loads extra data reusing LoadState<PokemonDetail>.
  • Navigation is testable without opening the simulator.

What’s next

In part 4 shared state arrives: favorites. You mark a star in the detail and see it appear in the list without hand-rolled bindings or notifications. It’s the case where @Shared with .fileStorage replaces what in the MVVM Pokédex we did with SwiftData in part 6, with meaningfully less friction.

Sources: Tree-based navigation (docs) · app repo