SwiftTCAArquitecturaSwiftUIiOS

TCA desde cero (parte 1): construyendo un buscador con la API de 2026

El ejemplo clásico para explicar TCA es un contador. Sirve para ver las piezas, pero un contador no tiene red, ni cancelación, ni dependencias que mockear, ni estado que compartir — justo los problemas por los que elegirías TCA.

En esta serie armamos un buscador con debounce y cancelación, algo que existe en prácticamente cualquier app de producción, y los conceptos de TCA van saliendo del camino conforme los vamos necesitando.

Un aviso antes de empezar: TCA cambió mucho. La versión actual es 1.26 (junio 2026), y la 1.25 depreció una buena parte de la API que sigue circulando de hace un par de años: ViewStore, WithViewStore, BindingViewStore, Store.withState. Todo lo que escribimos aquí usa la API vigente.

Las tres piezas

TCA se sostiene sobre tres ideas. Vale la pena nombrarlas antes de escribir código, aunque las entenderás mejor al usarlas:

  • El state es todo lo que tu feature necesita saber, en un solo lugar.
  • Las actions son todo lo que puede pasar: toques del usuario, respuestas de red, temporizadores. Conviene pensarlas como cosas que ya ocurrieron.
  • El reducer es la función que, dado un estado y una acción, decide el nuevo estado y qué efectos disparar.

La regla de oro es que el reducer sea una función pura. No hace red, no toca disco, no lee el reloj. Cuando necesita algo del mundo real, devuelve un efecto que lo describe. A mí esa separación fue la que más me costó interiorizar viniendo de MVVM, y es la que después hace que todo sea testeable.

Paso 1: el estado y las acciones

Empecemos por lo mínimo. Nuestro buscador necesita saber qué escribió el usuario y qué resultados tiene:

import ComposableArchitecture

@Reducer
struct BuscadorFeature {
    @ObservableState
    struct State: Equatable {
        var consulta = ""
        var resultados: [Repositorio] = []
    }

    enum Action {
        case consultaCambiada(String)
        case resultadosRecibidos([Repositorio])
    }
}

Dos macros hacen el trabajo pesado. @Reducer genera el andamiaje (conformidad al protocolo, tipos asociados, y más adelante lo que necesitamos para componer). @ObservableState conecta el estado con el sistema de Observation de Swift, y es lo que permite que la vista lea store.consulta directamente, sin ViewStore ni WithViewStore. Si te encuentras código con WithViewStore { viewStore in ... }, viene de una versión anterior.

Fíjate en los nombres de las acciones: consultaCambiada, resultadosRecibidos. Están en pasado porque describen hechos. Parece una convención cosmética, pero cambia cómo piensas la feature: el reducer reacciona a lo que ya pasó.

Paso 2: el reducer

Ahora la lógica. El cuerpo del reducer va en la propiedad body:

@Reducer
struct BuscadorFeature {
    @ObservableState
    struct State: Equatable {
        var consulta = ""
        var resultados: [Repositorio] = []
    }

    enum Action {
        case consultaCambiada(String)
        case resultadosRecibidos([Repositorio])
    }

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case let .consultaCambiada(texto):
                state.consulta = texto
                return .none

            case let .resultadosRecibidos(repos):
                state.resultados = repos
                return .none
            }
        }
    }
}

Tres cosas que notar:

  1. state es inout, así que lo mutas directamente, sin copias ni return newState. Se siente imperativo, pero como el reducer es puro sigue siendo predecible.
  2. Cada rama devuelve un efecto. .none significa que no hay nada más que hacer. En un momento devolvemos algo más interesante.
  3. El switch es exhaustivo. Si agregas una acción y olvidas manejarla, el compilador te detiene. En un ViewModel con métodos sueltos sí existe la acción que se te olvidó conectar; aquí no.

Paso 3: conectar la vista

La vista recibe un Store y lee el estado directamente:

import SwiftUI
import ComposableArchitecture

struct BuscadorView: View {
    @Bindable var store: StoreOf<BuscadorFeature>

    var body: some View {
        List(store.resultados) { repo in
            VStack(alignment: .leading) {
                Text(repo.nombre).font(.headline)
                Text(repo.descripcion).font(.subheadline).foregroundStyle(.secondary)
            }
        }
        .searchable(text: $store.consulta.sending(\.consultaCambiada))
    }
}

Aquí pasan dos cosas importantes. @Bindable var store te permite derivar bindings con $store, sin pasar por ViewStore. Y .sending(\.consultaCambiada) convierte ese binding en envío de acciones: cuando el usuario escribe, se manda .consultaCambiada(texto) al store.

La vista nunca muta el estado. Solo describe lo que ve y avisa lo que pasó.

Un atajo para formularios

Si tu feature tiene muchos campos (un formulario de ajustes, por ejemplo), escribir una acción por campo es tedioso. Para eso existe BindingReducer:

enum Action: BindableAction {
    case binding(BindingAction<State>)
}

var body: some ReducerOf<Self> {
    BindingReducer()
    Reduce { state, action in
        // tu lógica
    }
}

Y en la vista, $store.consulta funciona directo, sin .sending(...). Sigue siendo la forma recomendada en la versión actual.

Paso 4: el primer efecto

Hasta ahora no hemos tocado la red. El reducer no puede hacer llamadas asíncronas, pero puede devolver un efecto que las describa:

case let .consultaCambiada(texto):
    state.consulta = texto
    return .run { send in
        let repos = try await buscarRepos(texto)
        await send(.resultadosRecibidos(repos))
    }

.run crea un efecto asíncrono. Dentro puedes usar await normalmente, y cuando tengas el resultado lo mandas de vuelta al reducer con send. El ciclo se cierra: acción → efecto → acción.

Pero este código tiene dos bugs que en producción se notan.

El primero es que dispara en cada tecla: si el usuario escribe “swift”, lanzas cinco búsquedas.

El segundo es una condición de carrera. Si la búsqueda de “swi” tarda más que la de “swift”, los resultados viejos llegan después y pisan a los nuevos, y el usuario termina viendo resultados que no corresponden a lo que escribió.

Paso 5: debounce y cancelación

TCA resuelve ambos con los identificadores de cancelación.

private enum CancelID { case busqueda }

// ...

case let .consultaCambiada(texto):
    state.consulta = texto

    guard !texto.isEmpty else {
        state.resultados = []
        return .cancel(id: CancelID.busqueda)
    }

    return .run { send in
        try await Task.sleep(for: .milliseconds(300))   // debounce
        let repos = try await buscarRepos(texto)
        await send(.resultadosRecibidos(repos))
    }
    .cancellable(id: CancelID.busqueda, cancelInFlight: true)

La clave es cancelInFlight: true. Cada vez que llega una nueva pulsación, el efecto anterior con el mismo ID se cancela automáticamente, así que solo sobrevive la última búsqueda. Y el Task.sleep inicial termina funcionando como debounce, porque se cancela antes de completarse si el usuario sigue escribiendo.

A mano, en un ViewModel, esto es guardar el Task en una propiedad, cancelarlo en cada cambio y acordarte de limpiarlo cuando la vista desaparece. Aquí es una línea.

Paso 6: la dependencia

Queda un problema serio: buscarRepos sale de la nada. Si es una función global que llama a la red, no puedes testear la feature sin internet.

TCA trae su propio sistema de inyección de dependencias. Primero declaras el cliente como una struct de closures:

struct ReposClient {
    var buscar: (String) async throws -> [Repositorio]
}

extension ReposClient: DependencyKey {
    // La implementación real, la que usa la app
    static let liveValue = ReposClient(
        buscar: { consulta in
            let url = URL(string: "https://api.example.com/search?q=\(consulta)")!
            let (data, _) = try await URLSession.shared.data(from: url)
            return try JSONDecoder().decode([Repositorio].self, from: data)
        }
    )

    // La implementación para tests: predecible y sin red
    static let testValue = ReposClient(
        buscar: { _ in [] }
    )
}

extension DependencyValues {
    var reposClient: ReposClient {
        get { self[ReposClient.self] }
        set { self[ReposClient.self] = newValue }
    }
}

¿Por qué una struct de closures y no un protocolo? Porque te deja sustituir una sola función en un test sin implementar toda la interfaz. Es una decisión deliberada de la librería, y se agradece cuando tu cliente tiene quince métodos y el test solo necesita cambiar uno.

Ahora la usas en el reducer:

@Reducer
struct BuscadorFeature {
    @Dependency(\.reposClient) var reposClient
    @Dependency(\.continuousClock) var clock

    // ...

    case let .consultaCambiada(texto):
        state.consulta = texto
        guard !texto.isEmpty else {
            state.resultados = []
            return .cancel(id: CancelID.busqueda)
        }
        return .run { [reposClient, clock] send in
            try await clock.sleep(for: .milliseconds(300))
            let repos = try await reposClient.buscar(texto)
            await send(.resultadosRecibidos(repos))
        }
        .cancellable(id: CancelID.busqueda, cancelInFlight: true)
}

Aparecieron dos dependencias. La segunda es el reloj: cambié el Task.sleep del paso anterior por clock.sleep, y la razón es puramente de testing. Con Task.sleep, tu test tendría que esperar 300 ms reales. Con un reloj inyectado puedes sustituirlo por uno que no espera nada. Multiplícalo por cien tests y la diferencia entre una suite de un segundo y una de medio minuto está en esa línea.

El reducer ya no sabe cómo se busca ni cómo se espera. Solo sabe que existe algo que busca, y por eso se puede testear.

Paso 7: manejar el error

Un detalle fácil de pasar por alto: try await puede fallar, y si falla dentro de .run, el error se traga silenciosamente. En producción eso es una pantalla vacía sin explicación.

Agrega una acción para el fallo:

enum Action {
    case consultaCambiada(String)
    case resultadosRecibidos([Repositorio])
    case busquedaFallo(String)
}

Y captura el error en el efecto:

return .run { [reposClient, clock] send in
    try await clock.sleep(for: .milliseconds(300))
    let repos = try await reposClient.buscar(texto)
    await send(.resultadosRecibidos(repos))
} catch: { error, send in
    await send(.busquedaFallo(error.localizedDescription))
}
.cancellable(id: CancelID.busqueda, cancelInFlight: true)

Ojo con un detalle: la cancelación también lanza un error (CancellationError). Si no quieres mostrar un mensaje cada vez que el usuario escribe otra letra, filtra ese caso antes de reportar.

Paso 8: el test

Aquí es donde TCA te devuelve lo que invertiste en ceremonia. TestStore verifica cada cambio de estado, paso a paso:

import ComposableArchitecture
import Testing

@Test
func busquedaDevuelveResultados() async {
    let store = await TestStore(initialState: BuscadorFeature.State()) {
        BuscadorFeature()
    } withDependencies: {
        $0.continuousClock = ImmediateClock()
        $0.reposClient.buscar = { _ in
            [Repositorio(id: 1, nombre: "swift", descripcion: "El lenguaje")]
        }
    }

    await store.send(.consultaCambiada("swift")) {
        $0.consulta = "swift"
    }

    await store.receive(\.resultadosRecibidos) {
        $0.resultados = [Repositorio(id: 1, nombre: "swift", descripcion: "El lenguaje")]
    }
}

Varias cosas que vale la pena señalar de este test:

  • Sustituyes solo la closure que te importa (buscar), sin tocar el resto del cliente.
  • ImmediateClock elimina la espera: los 300 ms de debounce pasan al instante y el test corre en microsegundos.
  • El closure de send describe el estado esperado. Si el reducer cambia algo que no declaraste, el test falla; si declaras un cambio que no ocurre, también.
  • receive exige que la acción llegue. Si el efecto no manda .resultadosRecibidos, el test falla.
  • TestStore es exhaustivo por defecto, así que un efecto que tu feature dispara y tú no verificas también rompe el test.

Ese último punto es el que más se subestima. En un ViewModel típico, un efecto sin verificar simplemente no aparece en la cobertura y nadie se entera.

Lo que ganamos (y lo que costó)

Repasemos lo que construimos: un buscador con debounce, cancelación de peticiones en vuelo, manejo de errores, dependencia inyectable y un test que verifica el flujo completo sin tocar la red.

Lo que TCA nos dio:

  • Cancelación declarativa en una línea, sin gestionar Task a mano.
  • El switch exhaustivo, que impide olvidar una acción.
  • Tests que verifican el estado y también los efectos.
  • Una dependencia sustituible sin escribir un protocolo completo.

Lo que costó:

  • Más ceremonia que un @Observable con dos propiedades. Para una pantalla trivial es exceso.
  • Una curva de aprendizaje real: macros, efectos, dependencias y un modelo mental distinto.
  • Tiempos de compilación mayores en proyectos grandes.

TCA no es gratis. Vale la pena cuando tu feature tiene estado complejo y efectos concurrentes, y cuando de verdad vas a escribir tests. Para una pantalla de “acerca de”, no.

Qué sigue

Ya tenemos una feature completa y aislada. Pero las apps reales no son una pantalla: son decenas de features que navegan entre sí, comparten estado y se agrupan en módulos.

En la parte 2 vamos a componer: cómo una feature contiene a otra, cómo se navega con estado y por qué el enum @Reducer para destinos cambió la forma de modelar navegación en TCA.

Después llegarán el estado compartido entre features, el testing a fondo, y una comparativa honesta contra MVVM y las otras arquitecturas. Y al final de la serie, reconstruiremos la Pokédex de la serie anterior completamente en TCA, para comparar las dos aproximaciones sobre el mismo problema real.

Fuentes: swift-composable-architecture (GitHub) · Guía de migración a 1.25 · Documentación de Bindings