SwiftTCASwiftUIiOS

Pokédex en TCA (parte 5): búsqueda con debounce y filtros por tipo

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 Scope y el padre reaccionando a acciones del hijo.
  • Un solo LoadState para 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