Con la parte 4 tenemos favoritos que persisten y se sincronizan solos. Ahora agregamos dos cosas que en una app real siempre aparecen juntas: buscar por nombre y filtrar por tipo. Cada una toca un concepto TCA distinto.
La búsqueda es el caso escuela del debounce con cancelación en vuelo. Ya vimos el patrón sobre un ejemplo aislado en la parte 1 de TCA; aquí lo integramos a la app de verdad, con los bugs que aparecen cuando el usuario ya tiene datos cargados.
Los filtros son la primera sub-feature “real” de la Pokédex. Los modelamos como su propio Reducer con BindingReducer para los toggles, los componemos en el padre con Scope, y el padre reacciona a las acciones del hijo para recargar la lista.
Búsqueda: primero el estado
La búsqueda no es un modo separado de la app: convive con la lista. El usuario puede empezar a escribir en cualquier momento y volver al listado sin más. En vez de dos estados paralelos (.browsing / .searching), un solo LoadState<[Pokemon]> que se actualiza según lo que corresponda:
@ObservableState
struct State: Equatable {
var loadState: LoadState<[Pokemon]> = .idle
var isLoadingMore = false
var canLoadMore = true
@Presents var destination: Destination.State?
@SharedReader(.favorites) var favorites
// nuevo:
var query: String = ""
}
Las acciones nuevas:
enum Action {
// ... acciones anteriores ...
case queryChanged(String)
case searchReceived([Pokemon])
case searchFailed(String)
}
Y un CancelID para la búsqueda:
private enum CancelID { case initial, more, search }
El reducer de la búsqueda
case let .queryChanged(text):
state.query = text
guard !text.isEmpty else {
// El usuario borró todo: cancelamos la búsqueda en vuelo
// y volvemos al listado inicial.
state.loadState = .idle
return .merge(
.cancel(id: CancelID.search),
.send(.onAppear)
)
}
state.loadState = .loading
return .run { [pokemonClient, clock] send in
try await clock.sleep(for: .milliseconds(300))
let results = try await pokemonClient.search(text)
await send(.searchReceived(results))
} catch: { error, send in
// Filtramos cancelaciones: no son errores para el usuario.
if error is CancellationError { return }
await send(.searchFailed(error.localizedDescription))
}
.cancellable(id: CancelID.search, cancelInFlight: true)
case let .searchReceived(results):
state.loadState = .loaded(results)
state.canLoadMore = false
return .none
case let .searchFailed(message):
state.loadState = .failed(message)
return .none
Cinco cosas que valen la pena.
Uno, cuando la búsqueda queda vacía volvemos a .onAppear. Esto es lo bonito de tener las acciones nombradas: reusar una es una línea. .merge deja disparar dos efectos en el mismo ciclo (cancelar la búsqueda y arrancar la carga inicial).
Dos, el clock.sleep(for: .milliseconds(300)) es el debounce. Cambié Task.sleep por una dependencia inyectada (@Dependency(\.continuousClock) var clock) desde la parte 1 del post; aquí se aprovecha porque el test de este flujo será instantáneo con un ImmediateClock.
Tres, cancelInFlight: true es la protección: si el usuario escribe rápido, cada nueva pulsación cancela la búsqueda anterior. Solo sobrevive la última. Sin esto, resultados viejos llegan tarde y pisan a los nuevos.
Cuatro, filtramos CancellationError en el catch. La cancelación también lanza, y sin este filtro cada letra que borre una búsqueda en vuelo pondría .failed("cancelled") en pantalla por medio segundo.
Cinco, canLoadMore = false en .searchReceived. La PokeAPI no pagina resultados de búsqueda igual que el listado, así que desactivamos la paginación mientras haya búsqueda activa. Cuando el usuario borra la query y volvemos a .onAppear, la lista inicial vuelve a paginar.
Necesitamos agregar search al cliente:
struct PokemonClient {
var fetchList: (_ limit: Int, _ offset: Int) async throws -> [Pokemon]
var fetchDetail: (_ id: Int) async throws -> PokemonDetail
var search: (_ query: String) async throws -> [Pokemon]
}
Los filtros como sub-feature
Los filtros son un buen candidato a sub-feature porque:
- Tienen su propio estado (los tipos seleccionados).
- Tienen su propia UI (una fila de chips o un sheet).
- Podrían mañana usarse desde otra pantalla (por ejemplo, favoritos filtrados).
@Reducer
struct FiltersFeature {
@ObservableState
struct State: Equatable {
var selectedTypes: Set<PokemonType> = []
}
enum Action: BindableAction {
case binding(BindingAction<State>)
case clearTapped
}
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .clearTapped:
state.selectedTypes = []
return .none
case .binding:
return .none
}
}
}
}
BindingReducer es el atajo que evita escribir una acción por cada campo. Con esto y @Bindable var store en la vista, un Toggle(isOn: $store.selectedTypes.contains(...)) o un NavigationLink que muta selectedTypes funcionan sin cablear nada más.
Componiendo en el padre
Con Scope conectamos la sub-feature al estado del padre:
@Reducer
struct PokemonListFeature {
@ObservableState
struct State: Equatable {
// ... campos anteriores ...
var filters = FiltersFeature.State()
}
enum Action {
// ... acciones anteriores ...
case filters(FiltersFeature.Action)
}
var body: some ReducerOf<Self> {
Scope(state: \.filters, action: \.filters) {
FiltersFeature()
}
Reduce { state, action in
switch action {
// ... casos anteriores ...
case .filters(.binding(\.selectedTypes)):
// Cuando el usuario cambia los filtros, recargamos la
// lista aplicando los nuevos criterios.
return startInitialLoad(state: &state)
case .filters:
return .none
}
}
.ifLet(\.$destination, action: \.destination) {
Destination()
}
}
}
Fíjate en case .filters(.binding(\.selectedTypes)): el padre reacciona específicamente cuando el hijo cambia selectedTypes. El hijo no sabe que hay un padre escuchando; el padre decide qué significa un cambio de filtros (en este caso, recargar). Es composición real: dependencias explícitas, ningún delegado, ningún NotificationCenter.
El startInitialLoad necesita ahora leer los filtros para pasárselos al cliente:
private func startInitialLoad(state: inout State) -> Effect<Action> {
state.loadState = .loading
let types = state.filters.selectedTypes
return .run { [pokemonClient, pageSize] send in
let list = try await pokemonClient.fetchList(pageSize, 0, types)
await send(.listReceived(list))
} catch: { error, send in
await send(.listFailed(error.localizedDescription))
}
.cancellable(id: CancelID.initial, cancelInFlight: true)
}
fetchList gana un parámetro más (Set<PokemonType>) que la implementación live traduce a query params de la PokeAPI. En la sección de tipos, la PokeAPI devuelve por endpoint distinto (/type/{name}), y el PokemonClient esconde la diferencia. Esto es lo que en la parte 4 de PokeTracker resolvíamos con el PokemonRepository; aquí la responsabilidad es idéntica, solo cambia la forma del tipo.
La vista de la lista con búsqueda y filtros
struct PokemonListView: View {
@Bindable var store: StoreOf<PokemonListFeature>
var body: some View {
contentView
.navigationTitle("Pokédex")
.searchable(text: $store.query.sending(\.queryChanged))
.toolbar {
ToolbarItem(placement: .primaryAction) {
NavigationLink {
FiltersView(store: store.scope(state: \.filters, action: \.filters))
} label: {
Image(systemName: store.filters.selectedTypes.isEmpty
? "line.3.horizontal.decrease.circle"
: "line.3.horizontal.decrease.circle.fill")
}
}
}
.navigationDestination(
item: $store.scope(\.destination, action: \.destination).detail
) { detailStore in
PokemonDetailView(store: detailStore)
}
.onAppear { store.send(.onAppear) }
}
@ViewBuilder
private var contentView: some View {
switch store.loadState {
case .idle, .loading:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case let .loaded(pokemons) where pokemons.isEmpty:
ContentUnavailableView.search
case let .loaded(pokemons):
loadedList(pokemons)
case let .failed(message):
errorView(message)
}
}
// loadedList y errorView como en la parte 2, con la fila de favoritos
// integrada como en la parte 4.
}
$store.query.sending(\.queryChanged) es el patrón moderno de binding a acciones: cuando el .searchable cambia el texto, se envía .queryChanged(nuevoTexto) al store. La vista no muta el estado, solo describe qué hacer con el cambio.
Nota el caso extra: case let .loaded(pokemons) where pokemons.isEmpty muestra ContentUnavailableView.search, que es la vista estándar de iOS para “no hay resultados”. La incorporamos aquí en vez de agregar otro caso al LoadState porque es una regla de UI, no un estado semánticamente distinto.
La vista de los filtros
struct FiltersView: View {
@Bindable var store: StoreOf<FiltersFeature>
var body: some View {
Form {
Section("Tipos") {
ForEach(PokemonType.allCases) { type in
Toggle(type.displayName, isOn: Binding(
get: { store.selectedTypes.contains(type) },
set: { on in
var next = store.selectedTypes
if on { next.insert(type) } else { next.remove(type) }
store.selectedTypes = next
}
))
}
}
if !store.selectedTypes.isEmpty {
Section {
Button("Limpiar", role: .destructive) {
store.send(.clearTapped)
}
}
}
}
.navigationTitle("Filtros")
}
}
Al escribir store.selectedTypes = next, BindingReducer genera la acción .binding(\.selectedTypes) automáticamente, que el padre está escuchando con el case .filters(.binding(\.selectedTypes)) de más arriba. Esa es toda la comunicación necesaria.
Lo que llevamos
- Búsqueda con debounce, cancelación en vuelo y filtrado de
CancellationError. - La misma feature muestra listado o búsqueda según haya query.
- Filtros como sub-feature con
BindingReducer. - Composición con
Scopey el padre reaccionando a acciones del hijo. - Un solo
LoadStatepara lista, búsqueda y filtros: los tres estados son “carga de una lista”.
Qué sigue
Con esto tenemos la app completa: lista con paginación, detalle con datos extra, favoritos persistidos, búsqueda y filtros. Falta el capítulo que en la parte 9 de PokeTracker cubrimos con tests unitarios de view models y snapshots: en la parte 6 escribimos tests con TestStore que cubren flujos completos (“cargar lista, tocar fila, marcar favorito, volver”) y cierran la serie con la retrospectiva honesta: qué duplicó código en TCA, qué simplificó, qué duele en producción y en qué escenario elegiría TCA sobre MVVM para una app real.
Fuentes: swift-composable-architecture · PokeAPI · repo de la app