El flujo unidireccional: estado abajo, acciones arriba
El principio que ordena toda la arquitectura es que la información circula en una sola dirección: el estado baja del `Store` a la vista, que es una función pura de ese estado, y las acciones suben de la vista al `Store`, que es el único que puede provocar un cambio. Es el mismo ciclo cerrado de Elm —vista, mensaje, actualización, vista— trasladado a SwiftUI. Esta lección explica las dos mitades del bucle con su código idiomático, muestra que en TCA la vista no muta nunca sino que solo envía con `send`, aclara cómo incluso los bindings de un `TextField` respetan el ciclo al convertirse en acciones, y argumenta por qué un único camino para el cambio —a costa de más ceremonia que el binding bidireccional— es lo que hace que el comportamiento de la app sea legible, reproducible y comprobable.
Si hubiera que reducir TCA a una sola frase, sería esta: el estado baja, las acciones suben. El Store entrega el estado a la vista, que se dibuja como una función pura de ese estado; y cuando el usuario interactúa, la vista no cambia nada por su cuenta, sino que envía una acción de vuelta al Store, único lugar con permiso para provocar el cambio. El resultado es un bucle cerrado que gira siempre en el mismo sentido —el mismo ciclo que Elm popularizó con su trío vista, mensaje y actualización— y esa unidireccionalidad no es un capricho estético: es lo que garantiza que exista un solo camino por el que el estado puede cambiar, y por tanto un solo lugar donde buscar cuando algo va mal.
- Enunciar el principio del flujo unidireccional: el estado baja a la vista, las acciones suben al
Store. - Ver que la vista es una función del estado y que nunca lo muta directamente, solo envía con
send. - Entender cómo los bindings de formulario respetan el ciclo al traducirse en acciones.
- Argumentar por qué un único camino para el cambio compensa la ceremonia frente al binding bidireccional.
Estado abajo: la vista es una función del estado
La primera mitad del bucle es la bajada. La vista no guarda estado propio sobre la feature: recibe un StoreOf<Contador> y lee de él todo lo que necesita pintar. Cuando el estado del Store cambia, la vista se recalcula, porque en SwiftUI una vista es, por definición, una función de su estado. Esto convierte el renderizado en algo trivialmente predecible: dado un State, la vista siempre pinta lo mismo, sin importar cómo se llegó a ese estado. No hay historia oculta ni mutaciones parciales; la pantalla es un reflejo directo del valor actual.
struct ContadorView: View {
let store: StoreOf<Contador>
var body: some View {
VStack {
Text("\(store.cuenta)") // estado abajo: la vista LEE del store
HStack {
Button("-") { store.send(.decremento) }
Button("+") { store.send(.incremento) }
}
}
}
}
Acciones arriba: la vista no muta, envía
La segunda mitad es la subida. Cuando el usuario pulsa un botón, la vista no escribe store.cuenta += 1 —eso sería mutar el estado desde fuera del reducer, exactamente lo que la arquitectura prohíbe—. En su lugar llama a store.send(.incremento): informa de un hecho y deja que el Reducer decida. El estado es de solo lectura desde la vista; la única forma de cambiarlo es enviar una acción y esperar a que el bucle dé la vuelta. Esta asimetría —leer directo, escribir solo por acción— es la esencia del flujo unidireccional, y es lo que impide que el estado cambie por caminos que nadie vigila.
Podría objetarse que los formularios rompen esto, porque un TextField necesita un binding bidireccional para funcionar. TCA lo resuelve sin traicionar el principio: el binding no muta el estado directamente, sino que cada tecleo se traduce en una acción que sube al Store como cualquier otra. Con @Bindable sobre el store y un BindingReducer en la feature, el ciclo se mantiene intacto aunque la sintaxis parezca un binding normal.
struct FormularioView: View {
@Bindable var store: StoreOf<Formulario>
var body: some View {
Form {
// Parece un binding normal, pero cada cambio ENVIA una accion al store.
TextField("Nombre", text: $store.nombre)
Toggle("Recordarme", isOn: $store.recordar)
}
}
}
El ciclo cerrado: de la acción al estado y de vuelta
Uniendo las dos mitades aparece el bucle completo, y es idéntico al de Elm. La vista envía una Action al Store; el Store se la pasa al Reducer; el Reducer muta el State y quizá devuelve un Effect; el Store guarda el nuevo estado, y como la vista es función de ese estado, se redibuja. Si el reducer devolvió un efecto, este corre por fuera y, al terminar, envía una nueva Action que vuelve a entrar por el mismo punto. El bucle nunca se salta pasos ni cambia de sentido: toda información que sube es una acción, toda información que baja es estado, y en medio siempre está el reducer.
flowchart LR VIEW[Vista] -->|send Action| STORE[Store] STORE -->|pasa la accion| REDUCER[Reducer] REDUCER -->|muta| STATE[State] STATE -->|estado abajo| VIEW REDUCER -->|devuelve| EFFECT[Effect] EFFECT -->|nueva Action| STORE style VIEW fill:#89b4fa,color:#11111b style STORE fill:#cba6f7,color:#11111b style REDUCER fill:#f9e2af,color:#11111b style STATE fill:#a6e3a1,color:#11111b style EFFECT fill:#f38ba8,color:#11111b
Estado abajo
El Store entrega el State a la vista, que se dibuja como función pura de él. Dado un estado, la pantalla es siempre la misma.
Acciones arriba
La vista nunca muta: con store.send informa de un hecho. El estado es de solo lectura para todo lo que no sea el Reducer.
Un ciclo cerrado
Acción, reducer, estado, redibujo. Los efectos cierran el bucle enviando nuevas acciones. Siempre el mismo sentido.
Los bindings también suben
Un TextField con @Bindable no escapa al ciclo: cada tecleo se convierte en una acción que pasa por el reducer.
La tentación permanente al usar TCA es lamentar su ceremonia: en SwiftUI a secas, un @State con binding bidireccional te deja mutar un valor desde la vista en una línea, mientras que TCA te obliga a declarar una acción, manejarla en el reducer y esperar a que el bucle dé la vuelta. Esa comparación es justa pero incompleta, porque mide la comodidad de escribir un cambio e ignora el coste de entender todos los cambios. El binding bidireccional es cómodo precisamente porque esconde la causa: cuando un valor cambia, no hay un lugar único que lo explique, sino tantos como bindings apunten a él, y depurar una app así es reconstruir a mano una historia que el código nunca escribió. El flujo unidireccional invierte ese trato. Paga por adelantado la ceremonia de que todo cambio sea una acción explícita, y a cambio obtiene una propiedad que ninguna cantidad de bindings puede dar: existe un solo camino por el que el estado cambia —la puerta del reducer— y por tanto un solo lugar donde leer, registrar y reproducir todo lo que le ha pasado a la app. Ese registro no es teórico: como cada acción es un dato, TCA puede imprimir la secuencia completa de acciones que llevó a un bug, o reproducirla en un test hasta el estado exacto del fallo. El binding bidireccional optimiza el momento de escribir; el flujo unidireccional optimiza los años de leer, depurar y mantener. En una feature pequeña gana el primero, y por eso SwiftUI lo ofrece por defecto; en una app grande y longeva gana el segundo, y por eso existe TCA. Elegir entre ambos no es elegir entre lo fácil y lo difícil, sino entre la comodidad local y la legibilidad global, que es el mismo intercambio que atraviesa todo este track.
- Toma una vista de TCA y marca cada punto donde LEE del store —el estado que baja— y cada punto donde llama a
send—la acción que sube—. Confirma que nunca muta el estado directamente. - Busca un
TextFieldoTogglecon@Bindabley localiza la acción de binding en la que se traduce cada cambio. Verifica que pasa por el reducer como cualquier otra. - Elige una interacción y traza el bucle entero en orden: acción enviada, mutación del reducer, efecto lanzado, nueva acción de vuelta, estado final, redibujo de la vista.
- Toma un fragmento de SwiftUI con binding bidireccional y cuenta desde cuántos sitios distintos puede cambiar un mismo valor. Compáralo con el único camino de la versión TCA.
- Argumenta en tres líneas, para una feature concreta, si la ceremonia del flujo unidireccional compensa aquí o si el binding directo es la elección correcta.