SwiftTCAArchitectureSwiftUIiOS

Pokédex in TCA (part 2): remote state, pagination and visible errors

In part 1 we set up the first screen: a list of Pokémon loaded from the PokeAPI. It works, but it has two debts that bite early in a real app. One is the isLoading boolean, which doesn’t tell “I haven’t loaded yet” from “I loaded and there’s nothing”; the other is the error, which in part 1 landed in listFailed and got thrown away, leaving the UI blank without saying why. Both go away in this part.

The boolean problem

An isLoading boolean only models two states, and a network-backed app has at least four:

  • I haven’t requested anything yet (idle).
  • I’m requesting (loading).
  • I have the list (loaded).
  • It failed (failed, with the reason).

When you model that with a boolean and an empty array, your view ends up littered with if store.isLoading && store.pokemons.isEmpty, if store.pokemons.isEmpty && !store.isLoading, and other combinations. And worse: nothing stops you from having isLoading = true with a full list. That invalid state is representable, and eventually somebody produces it.

The fix is a type that captures exactly the possible states:

enum LoadState<Value: Equatable>: Equatable {
    case idle
    case loading
    case loaded(Value)
    case failed(String)
}

Generic because we’ll reuse it in more features. With this, each screen has a single LoadState<...> in its state, and the view does an exhaustive switch on it. Impossible states stop compiling.

PokemonListFeature with LoadState

We refactor the part 1 feature:

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

    enum Action {
        case onAppear
        case retryTapped
        case listReceived([Pokemon])
        case listFailed(String)
        case reachedEnd
        case moreReceived([Pokemon])
        case moreFailed(String)
    }

    @Dependency(\.pokemonClient) var pokemonClient

    private enum CancelID { case initial, more }
    private let pageSize = 20
}

loadState covers the initial load. isLoadingMore and canLoadMore cover pagination, which lives on a different axis: when you already have a list but are asking for more. They could go into a separate enum, but a couple of booleans here is more honest than over-engineering.

The CancelIDs are the tool TCA gives us to cancel in-flight effects. One for the initial load and another for pagination, because they’re independent: if the user retries the initial load we don’t want to kill a pagination that was halfway through, or the other way around.

The reducer

var body: some ReducerOf<Self> {
    Reduce { state, action in
        switch action {
        case .onAppear:
            if case .loaded = state.loadState { return .none }
            return startInitialLoad(state: &state)

        case .retryTapped:
            return startInitialLoad(state: &state)

        case let .listReceived(list):
            state.loadState = .loaded(list)
            state.canLoadMore = list.count == pageSize
            return .none

        case let .listFailed(message):
            state.loadState = .failed(message)
            return .none

        case .reachedEnd:
            guard
                case let .loaded(current) = state.loadState,
                state.canLoadMore,
                !state.isLoadingMore
            else { return .none }
            state.isLoadingMore = true
            let offset = current.count
            return .run { [pokemonClient, pageSize] send in
                let more = try await pokemonClient.fetchList(pageSize, offset)
                await send(.moreReceived(more))
            } catch: { error, send in
                await send(.moreFailed(error.localizedDescription))
            }
            .cancellable(id: CancelID.more, cancelInFlight: true)

        case let .moreReceived(more):
            guard case var .loaded(current) = state.loadState else { return .none }
            current.append(contentsOf: more)
            state.loadState = .loaded(current)
            state.isLoadingMore = false
            state.canLoadMore = more.count == pageSize
            return .none

        case .moreFailed:
            state.isLoadingMore = false
            return .none
        }
    }
}

private func startInitialLoad(state: inout State) -> Effect<Action> {
    state.loadState = .loading
    return .run { [pokemonClient, pageSize] send in
        let list = try await pokemonClient.fetchList(pageSize, 0)
        await send(.listReceived(list))
    } catch: { error, send in
        await send(.listFailed(error.localizedDescription))
    }
    .cancellable(id: CancelID.initial, cancelInFlight: true)
}

A few decisions worth calling out.

onAppear only fires the load if the state isn’t already .loaded. Before we used state.pokemons.isEmpty, which was ambiguous between a user opening the app for the first time and an API that returned an empty list. The enum states it without ambiguity.

retryTapped reuses the exact same helper as the initial onAppear, because a retry is nothing more than an onAppear that doesn’t care about the previous state. Extracting startInitialLoad avoids duplication and makes it clear they’re the same operation.

reachedEnd is the action the view fires when the last item becomes visible. The guard protects against three things: that we’re in error or loading (no list to paginate on), that the API has already told us there’s no more (canLoadMore == false), and that we aren’t already paginating (avoids double firing if the view bounces).

cancelInFlight: true on .more is the key protection: if the user scrolls fast and reachedEnd fires twice before the first response arrives, the second cancels the first. Without it, you end up inserting the same page twice back to back.

The view with an exhaustive switch

Here’s where the enum pays off:

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

    var body: some View {
        Group {
            switch store.loadState {
            case .idle, .loading:
                ProgressView()
                    .frame(maxWidth: .infinity, maxHeight: .infinity)

            case let .loaded(pokemons):
                loadedList(pokemons)

            case let .failed(message):
                errorView(message)
            }
        }
        .navigationTitle("Pokédex")
        .onAppear { store.send(.onAppear) }
    }

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

    private func errorView(_ message: String) -> some View {
        VStack(spacing: 12) {
            Text("Something went wrong").font(.headline)
            Text(message).font(.caption).foregroundStyle(.secondary)
            Button("Retry") { store.send(.retryTapped) }
                .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

The switch is the part you appreciate. If I add a case to LoadState tomorrow (say, .loadedEmpty to show a different message when the API returns zero results), the compiler forces me to handle it in the view. In a small app that looks like overkill; in an app with twenty screens it’s the difference between adding a feature with confidence and doing it with fingers crossed.

A note on onAppear on the last element: it’s the simple way to trigger pagination at the end. It works well for normal lists; if you need something fancier (like loading when 5 items are left), the logic goes here, not in the reducer.

The visible error

In part 1 the error landed in listFailed and vanished. Now it lives in the state as .failed(message) and the view shows it with a retry button. It’s the most basic interaction a user expects when something fails, and with the enum it almost writes itself.

An honest note: the error.localizedDescription we put in the state isn’t a pretty message. For a real app it’s worth mapping known errors to useful messages in the user’s language (No internet connection, The server took too long) and leaving the raw message for the generic case only. That lives in the network layer, not in the reducer, and it’s the same work we had to do in the MVVM Pokédex.

Cancellation across screens

There’s one case we don’t cover here that will show up in part 3: if the user is paginating and navigates to the detail, do we want pagination to keep running in the background? In TCA the answer comes from .ifLet when the screen is dismissed, but while the detail is presented, the list stays alive. The default behavior (pagination keeps running) is the reasonable one in most cases; if for some reason you wanted to pause it, you’d add an action on the parent that receives .pushedDetail and returns .cancel(id: CancelID.more). In this app that isn’t needed.

What we’ve got

  • Remote state is an enum, not two booleans that contradict each other.
  • The error is visible in the UI with a retry button, not a silent catch.
  • Pagination has in-flight cancellation, so a fast scroll doesn’t double pages.
  • The view does an exhaustive switch: impossible states don’t compile.

What’s next

In part 3 we open the detail. That’s where the pattern that changes most against MVVM shows up: navigation as state. Instead of a NavigationLink that pushes to the detail “when you tap it”, the detail is an optional value in the parent state, and presenting it is assigning to it. We’ll use @Presents with a destination enum, so that if we add more screens tomorrow (editor, modal sheet) the pattern is the same.

Sources: swift-composable-architecture · app repo