Closing the TCA series I left a promise: rebuild the PokeTracker Pokédex entirely in TCA, to see, on real code with all its edge cases, where TCA does the work for you and where it charges you. That starts here.
The series won’t re-explain things we already covered in PokeTracker that don’t change with TCA: creating the Xcode project, the network layer, the DTOs, the image cache or the CI/CD are the same. When one of those comes up I link it and move on. The focus of these posts is what does change: how state is modeled, how navigation works, how state is shared, and how tests are written.
The full code lives in pokedex-tca, a separate repo from the original Pokédex, so both apps can run side by side and we can compare properly at the end of the series.
What we’re building in this part
One screen: the list of Pokémon loaded from the PokeAPI. No detail, favorites or filters yet. The goal isn’t feature coverage but laying down how a TCA project is organized and seeing the first feature end to end.
By the end of this part we have a working list feature and the app boots from a root Store, which is the scaffold everything else will grow on.
Minimum setup
New Xcode project (or the same one we created in PokeTracker part 1, doesn’t matter), and add TCA as a Swift Package Manager dependency from File → Add Package Dependencies…:
https://github.com/pointfreeco/swift-composable-architecture
Pick the 1.x major version. At the time of writing the latest is 1.26 and every API I use in the series comes from there.
That’s all you need. TCA ships its own dependency system (swift-dependencies), so no additional packages.
Feature-based, not layer-based
In PokeTracker we organized the project by layers: Views/, ViewModels/, Services/, Repository/. It works and it’s what almost everyone does in MVVM.
In TCA the axis shifts. A feature isn’t a view plus a view model: it’s a Reducer with its State, its Action and its View, living and dying together. It makes sense for them to sit in the same folder.
The project looks like this:
PokedexTCA/
├── App/
│ ├── PokedexTCAApp.swift
│ └── RootFeature.swift
├── Features/
│ └── PokemonList/
│ ├── PokemonListFeature.swift
│ └── PokemonListView.swift
├── Core/
│ ├── Models/
│ │ └── Pokemon.swift
│ └── Clients/
│ └── PokemonClient.swift
└── Assets.xcassets
When we add the detail in part 3 it’ll be Features/PokemonDetail/. When favorites arrive in part 4, Features/Favorites/. The project grows by feature, not by file type.
The model and the client
The Pokemon model is identical to the previous series, so I bring it straight from PokeTracker part 3:
struct Pokemon: Equatable, Identifiable, Sendable {
let id: Int
let name: String
let spriteURL: URL?
let types: [String]
}
The client that talks to the PokeAPI does essentially the same work, but the shape changes. In the previous series it was a PokemonRepository class with methods; here it’s a struct of closures. I already explained why in TCA part 1, but the short version is that TCA prefers structs of closures because in a test you can swap only the function you care about, without implementing the whole interface.
import ComposableArchitecture
import Foundation
struct PokemonClient: Sendable {
var fetchList: @Sendable (_ limit: Int, _ offset: Int) async throws -> [Pokemon]
}
extension PokemonClient: DependencyKey {
static let liveValue = PokemonClient(
fetchList: { limit, offset in
// The real PokeAPI call goes here. The fetch logic, DTO
// decoding and mapping to `Pokemon` is the same as in
// PokeTracker part 2; skipped for brevity, and it lives
// complete in the repo.
try await PokeAPI.fetchPokemons(limit: limit, offset: offset)
}
)
static let testValue = PokemonClient(
fetchList: { _, _ in [] }
)
}
extension DependencyValues {
var pokemonClient: PokemonClient {
get { self[PokemonClient.self] }
set { self[PokemonClient.self] = newValue }
}
}
Two closures registered: the real one, which makes the HTTP request, and the test one, which returns an empty list by default. Any test will be able to override only the one it needs.
The Sendable on the struct and the @Sendable on the closures are demands of Swift 6’s strict concurrency mode. Without them, static let liveValue = ... fails to compile with “static property is not concurrency-safe”. Both the closures and anything they capture (here, nothing), plus the types they return (Pokemon), have to be Sendable.
PokemonListFeature
Here’s where it gets interesting. The whole feature fits in one file:
import ComposableArchitecture
@Reducer
struct PokemonListFeature {
@ObservableState
struct State: Equatable {
var pokemons: [Pokemon] = []
var isLoading = false
}
enum Action {
case onAppear
case listReceived([Pokemon])
case listFailed(String)
}
@Dependency(\.pokemonClient) var pokemonClient
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
guard state.pokemons.isEmpty, !state.isLoading else { return .none }
state.isLoading = true
return .run { [pokemonClient] send in
let list = try await pokemonClient.fetchList(20, 0)
await send(.listReceived(list))
} catch: { error, send in
await send(.listFailed(error.localizedDescription))
}
case let .listReceived(list):
state.pokemons = list
state.isLoading = false
return .none
case .listFailed:
state.isLoading = false
return .none
}
}
}
}
Three things worth noticing before moving on.
The guard at the start of onAppear is a small but important decision. The view fires onAppear every time it appears, and if the user pushes to a detail and comes back, it’d fire again. Without the guard we’d re-fetch the list even though we already have it. Modeling this kind of decision in the reducer, not in the view, is part of what TCA pushes you to do.
The error in listFailed for now just clears the spinner. In part 2 we’ll put it in the state and show it in the UI; I keep it minimal here because I don’t want to jump ahead to the loading-enum pattern.
And the switch is exhaustive. If I add a case to Action tomorrow and forget to handle it, the compiler stops me. That’s part of the TCA contract and part of what makes it hard to forget an action.
The view
import SwiftUI
import ComposableArchitecture
struct PokemonListView: View {
@Bindable var store: StoreOf<PokemonListFeature>
var body: some View {
List(store.pokemons) { pokemon in
HStack(spacing: 12) {
AsyncImage(url: pokemon.spriteURL) { image in
image.resizable().scaledToFit()
} placeholder: {
ProgressView()
}
.frame(width: 48, height: 48)
VStack(alignment: .leading) {
Text(pokemon.name.capitalized).font(.headline)
Text(pokemon.types.joined(separator: ", "))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.overlay {
if store.isLoading && store.pokemons.isEmpty {
ProgressView()
}
}
.navigationTitle("Pokédex")
.onAppear { store.send(.onAppear) }
}
}
@Bindable var store is the only syntactic concession. The view reads store.pokemons and store.isLoading directly, as if they were properties of an @Observable, without ViewStore or WithViewStore. And .onAppear { store.send(.onAppear) } is the only point where the view talks to the reducer. No hand-rolled bindings, no loading logic in the view.
The AsyncImage is the same one we used in PokeTracker part 7; here to avoid distracting, but when we get to the detail we’ll use the real image cache from that part, which integrates without friction with TCA because it lives outside the reducer.
Entry point with a single Store
In TCA the whole app shares a single root Store, and from there Stores for each feature are derived with scope. The practical reason is that navigation state, shared data and dependencies have to live somewhere, and that somewhere is the root.
We start with a RootFeature that for now only contains the list, but is ready to grow:
import ComposableArchitecture
@Reducer
struct RootFeature {
@ObservableState
struct State: Equatable {
var pokemonList = PokemonListFeature.State()
}
enum Action {
case pokemonList(PokemonListFeature.Action)
}
var body: some ReducerOf<Self> {
Scope(state: \.pokemonList, action: \.pokemonList) {
PokemonListFeature()
}
}
}
And the app:
import SwiftUI
import ComposableArchitecture
@main
struct PokedexTCAApp: App {
static let store = Store(initialState: RootFeature.State()) {
RootFeature()
}
var body: some Scene {
WindowGroup {
NavigationStack {
PokemonListView(
store: PokedexTCAApp.store.scope(
state: \.pokemonList,
action: \.pokemonList
)
)
}
}
}
}
The Store is created once and lives in a static let. This isn’t a disguised singleton: it’s how the app hands the root store to the root view. From there, each feature gets its Store via scope, and no feature knows there’s a root.
What we’ve got
With this we have:
- The structure of a TCA project that will grow by feature.
- An injectable client with live and test implementations.
- A complete feature (list) with state, actions, reducer, a network effect with error handling, and a view without logic.
- An entry point with a root
Storeready to add more features.
Compile it, run it, and you see the list of the first 20 Pokémon. It’s exactly what you saw at the end of the first PokeTracker parts; the difference is underneath, not on top.
What’s next
In part 2 we improve the remote state: instead of an isLoading boolean and an error thrown away, we model loading as an enum (.idle / .loading / .loaded / .failed), add pagination with cancellation of in-flight effects, and show the error in the UI in a way the user can retry.
Sources: swift-composable-architecture · PokeAPI · app repo