En la parte 1 montamos la primera pantalla: una lista de Pokémon cargada desde la PokeAPI. Funciona, pero tiene dos deudas que muerden pronto en una app real. Una es el isLoading booleano, que no distingue “aún no he cargado” de “cargué y no hay nada”; la otra es el error, que en la parte 1 caía a listFailed y se tiraba a la basura, dejando la UI en blanco sin decir por qué. En esta parte las dos se van.
El problema del booleano
Un booleano isLoading solo modela dos estados y una app de red tiene al menos cuatro:
- No he pedido nada aún (idle).
- Estoy pidiendo (loading).
- Tengo la lista (loaded).
- Falló (failed, con el motivo).
Cuando modelas eso con un booleano y un array vacío, tu vista termina llena de if store.isLoading && store.pokemons.isEmpty, if store.pokemons.isEmpty && !store.isLoading, y demás combinaciones. Y peor: nada te impide tener isLoading = true con una lista llena. Ese estado inválido es representable, y en algún momento aparece.
La solución es un tipo que capture exactamente los estados posibles:
enum LoadState<Value: Equatable>: Equatable {
case idle
case loading
case loaded(Value)
case failed(String)
}
Es genérico porque nos servirá para más features. Con esto, cada pantalla tiene un solo LoadState<...> en su estado, y la vista hace un switch exhaustivo sobre él. Los estados imposibles dejan de compilar.
PokemonListFeature con LoadState
Refactorizamos la feature de la parte 1:
@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 cubre la carga inicial. isLoadingMore y canLoadMore cubren la paginación, que se mueve en otro eje: cuando ya tienes lista pero estás pidiendo más. Podrían meterse en un enum aparte, pero un par de booleanos aquí es más honesto que sobreingeniería.
Los CancelID son la herramienta que TCA nos da para cancelar efectos en vuelo. Uno para la carga inicial y otro para la paginación, porque son independientes: si el usuario reintenta la carga inicial no queremos matar una paginación que estaba a mitad, ni al revés.
El 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)
}
Varias decisiones que vale la pena señalar.
El onAppear solo dispara la carga si el estado no está ya en .loaded. Antes usábamos state.pokemons.isEmpty, que era ambiguo con un usuario recién abriendo la app y con una API que devuelve lista vacía. Aquí el enum lo dice sin ambigüedad.
retryTapped reusa exactamente el mismo helper que onAppear inicial, porque un reintento no es más que un onAppear que no importa el estado previo. Extraer startInitialLoad evita duplicación y deja claro que son la misma operación.
reachedEnd es la acción que dispara la vista cuando el último item se hace visible. El guard protege contra tres cosas: que ya tengamos error o estemos en loading (no hay lista sobre la que paginar), que la API ya nos haya dicho que no hay más (canLoadMore == false), y que no estemos ya paginando (evita disparos duplicados si la vista rebota).
cancelInFlight: true en .more es la protección clave: si el usuario hace scroll rápido y reachedEnd se dispara dos veces antes de que llegue la primera respuesta, la segunda cancela a la primera. Sin eso, terminas insertando dos páginas iguales una detrás de otra.
La vista con switch exhaustivo
Aquí es donde el enum paga:
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("Algo salió mal").font(.headline)
Text(message).font(.caption).foregroundStyle(.secondary)
Button("Reintentar") { store.send(.retryTapped) }
.buttonStyle(.borderedProminent)
}
.padding()
}
}
El switch es la parte que se agradece. Si mañana agrego un caso al LoadState (por ejemplo, .loadedEmpty para dar un mensaje distinto cuando la API devuelve cero resultados), el compilador me obliga a manejarlo en la vista. En una app pequeña parece exagerado; en una con veinte pantallas es la diferencia entre agregar una feature con confianza y hacerlo rezando.
Un detalle sobre onAppear en el último elemento: es la forma sencilla de disparar paginación al final. Funciona bien para listas normales; si necesitas algo más sofisticado (por ejemplo cargar cuando faltan 5 items para el final), la lógica va aquí, no en el reducer.
El error visible
En la parte 1 el error caía a listFailed y desaparecía. Ahora vive en el estado como .failed(mensaje) y la vista lo muestra con un botón de reintento. Es la interacción más básica que un usuario espera cuando algo falla, y con el enum aparece casi sola.
Un tema honesto: el error.localizedDescription que ponemos en el estado no es un mensaje bonito. Para una app de verdad conviene mapear los errores conocidos a mensajes útiles en el idioma del usuario (Sin conexión a internet, El servidor tardó demasiado), y dejar el mensaje crudo solo para el caso genérico. Eso vive en la capa de red, no en el reducer, y es el mismo trabajo que teníamos que hacer en la Pokédex MVVM.
Cancelación entre pantallas
Hay un caso que no cubrimos aquí y que aparecerá en la parte 3: si el usuario está paginando y navega al detalle, ¿queremos que la paginación siga en segundo plano? En TCA la respuesta la da .ifLet cuando la pantalla se descarta, pero mientras el detalle está presentado, la lista sigue viva. La conducta por defecto (paginación que sigue corriendo) es la razonable en la mayoría de los casos; si por algún motivo quisieras pausarla, se hace añadiendo una acción en el padre que reciba .pushedDetail y devuelva .cancel(id: CancelID.more). En esta app no hace falta.
Lo que llevamos
- El estado remoto es un enum, no dos booleanos que se contradicen.
- El error es visible en la UI con un botón de reintento, no un
catchsilencioso. - La paginación tiene cancelación en vuelo, así que un scroll rápido no duplica páginas.
- La vista hace un
switchexhaustivo: los estados imposibles no compilan.
Qué sigue
En la parte 3 abrimos el detalle. Ahí aparece el patrón que cambia más frente a MVVM: la navegación como estado. En vez de un NavigationLink que empuja al detalle “cuando lo tocas”, el detalle es un valor opcional en el estado del padre, y presentarlo es asignárselo. Usaremos @Presents con un enum de destinos, para que si mañana agregamos más pantallas (editor, hoja modal) el patrón sea el mismo.
Fuentes: swift-composable-architecture · repo de la app