SwiftTCAArquitecturaSwiftUIiOS

Pokédex en TCA (parte 1): arranque y arquitectura de features

Al cerrar la serie de TCA me quedó una promesa: reconstruir la Pokédex de PokeTracker entera con TCA, para ver, sobre código real y con todos sus casos borde, dónde TCA hace el trabajo por ti y dónde te cobra. Aquí arrancamos.

La serie no vuelve a explicar cosas que ya cubrimos en PokeTracker y que no cambian con TCA: crear el proyecto en Xcode, la capa de red, los DTOs, la caché de imágenes o el CI/CD son los mismos. Cuando toque algo así lo enlazo y sigo. El foco de estos posts es lo que sí cambia: cómo se modela el estado, cómo se navega, cómo se comparte y cómo se testea.

El código completo vive en pokedex-tca, un repo aparte de la Pokédex original, para poder tener las dos apps ejecutándose lado a lado y comparar de verdad al final de la serie.

Lo que vamos a construir en esta parte

Una pantalla: la lista de Pokémon cargada desde la PokeAPI. Nada de detalle, favoritos ni filtros todavía. El objetivo no es la funcionalidad sino sentar cómo se organiza un proyecto TCA y ver la primera feature completa de principio a fin.

Al terminar tenemos una feature de lista funcionando y la app arranca desde un Store raíz, que es el andamio sobre el que crecerá todo lo demás.

Setup mínimo

Proyecto Xcode nuevo (o el mismo que se creó en la parte 1 de PokeTracker, da igual), y agregar TCA como dependencia de Swift Package Manager desde File → Add Package Dependencies…:

https://github.com/pointfreeco/swift-composable-architecture

Selecciona la versión mayor 1.x. Al momento de escribir esto la última es 1.26 y toda la API que uso en la serie viene de ahí.

Con eso tienes todo lo que necesitas. TCA trae su propio sistema de dependencias (swift-dependencies), así que no hay que agregar más paquetes.

Estructura por features, no por capas

En PokeTracker organizamos el proyecto por capas: Views/, ViewModels/, Services/, Repository/. Funciona y es lo que casi todo el mundo hace en MVVM.

En TCA cambia el eje. Una feature no es una vista más un view model: es un Reducer con su State, sus Action y su View, todo vivo y muriendo junto. Tiene sentido que vivan en la misma carpeta.

Así queda el proyecto:

PokedexTCA/
├── App/
│   ├── PokedexTCAApp.swift
│   └── RootFeature.swift
├── Features/
│   └── PokemonList/
│       ├── PokemonListFeature.swift
│       └── PokemonListView.swift
├── Core/
│   ├── Models/
│   │   └── Pokemon.swift
│   └── Clients/
│       └── PokemonClient.swift
└── Assets.xcassets

Cuando en la parte 3 agreguemos el detalle será Features/PokemonDetail/. Cuando en la parte 4 lleguen los favoritos, Features/Favorites/. El proyecto crece por features, no por tipos de archivo.

El modelo y el cliente

El modelo Pokemon es idéntico al de la serie anterior, así que lo traigo tal cual de la parte 3 de PokeTracker:

struct Pokemon: Equatable, Identifiable, Sendable {
    let id: Int
    let name: String
    let spriteURL: URL?
    let types: [String]
}

El cliente que habla con la PokeAPI también es esencialmente el mismo trabajo, pero cambia la forma. En la serie anterior era un PokemonRepository clase con métodos; aquí es una struct de closures. Ya expliqué el porqué en la parte 1 de TCA, pero el resumen es que TCA prefiere structs de closures porque en un test puedes sustituir solo la función que te importa, sin implementar toda la interfaz.

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
            // Aquí va la llamada real a la PokeAPI. La lógica del fetch,
            // el decoding de los DTO y el mapeo a `Pokemon` es lo mismo
            // que en la parte 2 de PokeTracker; se omite por brevedad y
            // vive completa en el 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 }
    }
}

Dos closures registradas: la real, que hace la petición HTTP, y la de test, que devuelve una lista vacía por defecto. Cualquier test podrá sobrescribir solo la que necesite.

El Sendable en el struct y el @Sendable en las closures son exigencias del modo estricto de concurrencia de Swift 6. Sin ellos, static let liveValue = ... falla al compilar con “static property is not concurrency-safe”. Tanto las closures como todo lo que capturen (aquí, ninguna captura), y los tipos que devuelven (Pokemon), tienen que ser Sendable.

PokemonListFeature

Aquí es donde empieza lo interesante. La feature completa cabe en un archivo:

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
            }
        }
    }
}

Tres cosas que notar antes de seguir.

El guard al inicio de onAppear es una decisión pequeña pero importante. La vista dispara onAppear cada vez que aparece, y si el usuario hace push a un detalle y vuelve, se dispararía otra vez. Sin el guard volveríamos a pedir la lista aunque ya la tuviéramos. Modelar esta clase de decisiones en el reducer, y no en la vista, es parte de lo que TCA te empuja a hacer.

El error en listFailed de momento solo apaga el spinner. En la parte 2 lo pondremos en el estado y lo mostraremos en la UI; aquí lo dejo minimal porque no quiero adelantar el patrón del enum de carga.

Y el switch es exhaustivo. Si mañana agrego un caso a Action y olvido manejarlo, el compilador me detiene. Eso es parte del contrato de TCA y de lo que hace que sea difícil olvidar una acción.

La vista

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 es la única concesión de sintaxis. La vista lee store.pokemons y store.isLoading directo, como si fueran propiedades de un @Observable, sin ViewStore ni WithViewStore. Y .onAppear { store.send(.onAppear) } es el único punto donde la vista habla con el reducer. No hay bindings a mano ni lógica de carga en la vista.

El AsyncImage es el mismo que usamos en la parte 7 de PokeTracker; aquí para no distraernos, pero cuando lleguemos al detalle usaremos la caché de imágenes real de aquella parte, que se integra sin fricción con TCA porque vive fuera del reducer.

Entry point con un solo Store

En TCA la app entera comparte un solo Store raíz, y desde ahí se derivan Store para cada feature con scope. La razón práctica es que el estado de navegación, los datos compartidos y las dependencias tienen que vivir en algún lado, y ese lado es la raíz.

Empezamos con un RootFeature que de momento solo contiene la lista, pero está listo para crecer:

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()
        }
    }
}

Y la 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
                    )
                )
            }
        }
    }
}

El Store se crea una vez y vive en una static let. Esto no es un singleton disfrazado: es cómo la app entrega el store raíz a la vista raíz. A partir de ahí, cada feature recibe su Store con scope, y ninguna sabe que hay una raíz.

Lo que llevamos

Con esto ya tenemos:

  • La estructura de un proyecto TCA que crecerá por features.
  • Un cliente inyectable con implementaciones live y test.
  • Una feature completa (lista) con estado, acciones, reducer, efecto de red con manejo de error, y vista sin lógica.
  • Un entry point con Store raíz listo para agregar más features.

Lo compilas, corres y ves la lista de los primeros 20 Pokémon. Es exactamente lo mismo que veías al terminar las primeras partes de PokeTracker; la diferencia está debajo, no arriba.

Qué sigue

En la parte 2 mejoramos el estado remoto: en lugar de un booleano isLoading y un error tirado a la basura, modelamos la carga como un enum (.idle / .loading / .loaded / .failed), agregamos paginación con cancelación de efectos en vuelo, y mostramos el error en la UI de forma que el usuario pueda reintentar.

Fuentes: swift-composable-architecture · PokeAPI · repo de la app