El Store y SwiftUI: conectar el motor a la vista
El Store es el runtime que da vida al reducer: mantiene el estado, ejecuta los efectos que el reducer devuelve y realimenta las acciones. Esta lección conecta ese motor a SwiftUI en la TCA de 2026: crear un StoreOf, observar el estado sin WithViewStore gracias a ObservableState y Bindable, enviar acciones con store.send —con animación incluida—, tender bindings de dos vías con BindingReducer y la sintaxis dólar-store, y usar scope para dividir un store de padre en stores de hijo y componer pantallas enteras a partir de features independientes.
El reducer es una ecuación pura y el Effect una descripción inerte; hace falta algo que les dé vida, que guarde el estado real, ejecute de verdad los efectos y cierre el ciclo realimentando las acciones. Ese algo es el Store: el runtime de la arquitectura, el único objeto de referencia en un mundo hecho de valores. La vista de SwiftUI no habla nunca con el reducer directamente; habla con el Store. Lee de él el estado para dibujarse y le envía acciones cuando el usuario toca la pantalla. Conectar el motor a la vista es, por tanto, aprender tres gestos —observar, enviar y enlazar— y una operación de composición —scope— que juntos convierten un puñado de features puras en una app que se ve y responde.
- Crear un
StoreOf<Feature>y entender su papel como runtime de referencia. - Observar el estado en SwiftUI con
@ObservableStatey@Bindable, sinWithViewStore. - Enviar acciones con
store.send, incluida la variante con animación. - Tender bindings de dos vías con
BindingReducery dividir el store conscope.
El Store: el runtime que orquesta el motor
Store<State, Action> —con el alias StoreOf<Feature>— es el único tipo de referencia de TCA, y su papel justifica la excepción: en un mundo de valores puros, alguien tiene que mantener el estado actual entre acciones, ejecutar los efectos que el reducer devuelve y reintroducir en reduce cada acción, venga del usuario o de un efecto. Eso es un runtime, y un runtime tiene identidad. Se crea una sola vez, con el estado inicial y el reducer, y se pasa hacia abajo por la jerarquía de vistas; no se recrea en cada redibujado.
@main
struct MiApp: App {
var body: some Scene {
WindowGroup {
ContadorView(
store: Store(initialState: Contador.State()) {
Contador()
}
)
}
}
}
La closure final construye el reducer con el mismo @ReducerBuilder que viste en body, así que ahí puedes envolverlo con ._printChanges() para depurar o apilar reducers de depuración sin tocar la feature.
Observar sin WithViewStore: la era de @ObservableState
Durante años, leer el estado en la vista obligaba a envolverlo: WithViewStore(store, observe: { $0 }) { viewStore in ... }, con el riesgo de observar de más y redibujar de menos. Con @ObservableState en el State (lección 2), esa envoltura desaparece. La vista guarda let store: StoreOf<Feature> y lee store.cuenta directamente; Observation refresca solo las vistas que leyeron el campo que cambió.
struct ContadorView: View {
let store: StoreOf<Contador>
var body: some View {
HStack {
Button("-") { store.send(.decrementarPulsado) }
Text("\(store.cuenta)")
Button("+") { store.send(.incrementarPulsado) }
}
}
}
En iOS 17 y posteriores esto usa Observation nativo. Para iOS 16 y anteriores, TCA da el mismo resultado con @Perception.Bindable y envolviendo el cuerpo en WithPerceptionTracking { }, que la macro te recuerda añadir si te olvidas.
Enviar acciones y animarlas
El segundo gesto es enviar. store.send(.accion) empuja una acción al reducer y devuelve un StoreTask que puedes await para saber cuándo terminaron los efectos que disparó —útil en un .task de la vista para lanzar la carga inicial y esperar su fin—. Y como el estado solo cambia a través del reducer, animar una transición es tan simple como decírselo al send: TCA envuelve la mutación del reducer en la transacción de animación que le pidas.
.task { await store.send(.alAparecer).finish() }
// ...
Button("Borrar") {
store.send(.borrarPulsado, animation: .default)
}
Que hasta la animación pase por una acción es coherente con el modelo: no hay una vía lateral para cambiar la UI, todo lo que se ve es una función del estado, y el estado solo cambia reduciendo acciones.
Bindings de dos vías: BindingReducer y $store
Los campos de formulario necesitan ir y volver: la vista escribe en el estado y el estado se refleja en la vista. TCA lo resuelve sin romper el flujo unidireccional. La Action conforma BindableAction y añade un caso binding(BindingAction<State>); el reducer incluye BindingReducer() en su body; y la vista usa @Bindable var store para producir bindings con $store.campo.
@Reducer
struct Formulario {
@ObservableState
struct State: Equatable {
var nombre = ""
var recibirCorreos = false
}
enum Action: BindableAction {
case binding(BindingAction<State>)
case enviarPulsado
}
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .binding: return .none
case .enviarPulsado: return .none
}
}
}
}
struct FormularioView: View {
@Bindable var store: StoreOf<Formulario>
var body: some View {
Form {
TextField("Nombre", text: $store.nombre)
Toggle("Recibir correos", isOn: $store.recibirCorreos)
Button("Enviar") { store.send(.enviarPulsado) }
}
}
}
Cuando el usuario teclea, $store.nombre no muta el estado a mano: envía una acción .binding(.set(\.nombre, nuevoValor)) que fluye por el reducer como cualquier otra, y BindingReducer() es la pieza que la aplica al State. Así, incluso escribir en un campo de texto es un evento auditable que atraviesa el núcleo puro.
flowchart LR V[vista SwiftUI] -->|envia accion| ST[Store runtime] ST -->|pasa a reduce| RD[reducer puro] RD -->|estado nuevo y Effect| ST ST -->|estado observable| V style ST fill:#89b4fa,color:#11111b style RD fill:#cba6f7,color:#11111b
scope: dividir el store por features
El cuarto gesto es de composición y sucede en la vista. Un store de padre expone, con scope, una porción de su estado y de sus acciones como un store de hijo, y la vista hija solo ve su rebanada.
struct AppView: View {
let store: StoreOf<AppFeature>
var body: some View {
TabView {
ContadorView(store: store.scope(state: \.contador, action: \.contador))
PerfilView(store: store.scope(state: \.perfil, action: \.perfil))
}
}
}
scope talla un store hijo del padre usando rutas de clave: \.contador para el estado y \.contador para la acción. La vista hija ignora que existe un padre, y TCA reincrusta cada acción del hijo en el flujo del padre. Para navegación y presentación —hojas, alertas, pilas— se usa la variante $store.scope(...) sobre estado marcado con @Presents. Esta composición en la vista es el espejo exacto de la composición de reducers del Nivel 28: el estado se descompone en features, y el store se descompone con él.
La lección final de TCA es entender por qué el Store es el único objeto de referencia en toda la arquitectura, y por qué eso es un acierto y no una concesión. Reducer, estado, acción y efecto son valores: puros, copiables, comparables, testeables sin escenario. Pero una app viva necesita identidad en algún punto —algo que persista entre pulsaciones, que recuerde el estado actual, que tenga tareas en marcha que cancelar— y TCA concentra toda esa identidad en un solo lugar, el Store, en vez de dispersarla por mil objetos con estado mutable como hace el MVC clásico. Esa concentración es la que hace tratable el sistema: hay exactamente una frontera entre el mundo puro donde razonas y el mundo de referencia donde ocurren las cosas, y esa frontera es el store. La vista, del lado impuro, solo puede hacer dos cosas a través de él —leer estado para dibujarse y enviar acciones— y ambas son unidireccionales: no hay una puerta trasera para mutar el estado saltándose el reducer, no hay un binding que escriba en un campo sin pasar por BindingReducer, no hay un Task legítimo fuera del Effect. Por eso, cuando conectas el motor a SwiftUI, no estás pegando dos mundos incompatibles con cinta adhesiva: estás cerrando un ciclo que empezó en el reducer puro, pasó por el efecto descrito y las dependencias inyectadas, y vuelve a la vista como estado observable. El store es el punto donde ese ciclo toca tierra una sola vez, y comprender que toda la complejidad de la identidad y del tiempo vive ahí, y solo ahí, es comprender por qué una app en TCA se puede razonar entera aunque hable con la red, mueva el reloj y pinte animaciones. La disciplina no desaparece en la vista: culmina en ella.
- Crea un
Store(initialState:) { Feature() }en el punto de entrada de la app y pásalo a la vista raíz. Explica por qué no debe recrearse en cada redibujado. - Escribe una
Viewque recibalet store: StoreOf<Feature>, lea un campo constore.cuentay envíe acciones constore.send, sin usarWithViewStore. - Convierte una acción en animada con
store.send(.accion, animation: .default)y razona por qué es correcto que hasta la animación pase por el reducer. - Monta un formulario con
BindableAction,BindingReducer()y@Bindable var store, y sigue el recorrido de una pulsación de tecla hasta elStatea través de.binding. - Divide una pantalla con dos hijos usando
store.scope(state:action:), y conecta esa composición de vistas con la composición de reducers que estudiarás en el Nivel 28.