El problema de los formularios: una acción por campo no escala
Un formulario es el lugar donde la disciplina unidireccional se vuelve incómoda. Cada campo editable exige un canal para viajar de la vista al estado, y si ese canal es un caso del enum de acciones, un registro de doce campos cuesta doce casos, doce ramas del switch y doce tests que no comprueban ninguna regla de negocio. Esta lección diagnostica por qué esa explosión no es un defecto de estilo sino una degradación semántica del tipo Action, descarta las salidas falsas que la gente intenta primero, y presenta la forma de la solución que TCA adopta: un único caso paramétrico que transporta qué campo cambió y a qué valor, aplicado por un reducer dedicado.
Hasta aquí la arquitectura ha sido cómoda porque los ejemplos tenían pocos sucesos y todos significaban algo: se pulsó un botón, llegó una respuesta, expiró un temporizador. Un formulario rompe esa comodidad de golpe. La edición de un campo de texto no es un suceso interesante —nadie quiere leerlo en una traza, nadie quiere testearlo aisladamente— pero necesita exactamente el mismo canal que los sucesos que sí importan, porque la vista no puede escribir en el estado por su cuenta. El resultado es un enum Action cuya mitad son setters disfrazados de mensajes. Este nivel entero existe para resolver esa tensión sin sacrificar ninguna de las garantías que la hacen valiosa: mutación en un solo punto, trazabilidad completa, testabilidad exhaustiva.
- Reconstruir el formulario ingenuo y cuantificar su coste real en casos, ramas y tests.
- Diagnosticar la degradación del
enum Actioncuando la mayoría de sus casos son asignaciones. - Descartar razonadamente las tres salidas falsas: mutar desde la vista, un caso genérico sin tipar y un modelo observable paralelo.
- Anticipar la forma de la solución de TCA como un caso paramétrico aplicado por un reducer dedicado.
El formulario escrito a mano
Considera una pantalla de alta de perfil con seis campos: nombre, apellidos, correo, biografía, notificaciones y visibilidad. Escrita con las herramientas de los niveles anteriores, la feature tiene esta forma, y no hay ningún atajo honesto que la haga más corta.
@Reducer
struct Perfil {
@ObservableState
struct State: Equatable {
var nombre = ""
var apellidos = ""
var correo = ""
var biografia = ""
var notificaciones = false
var visibilidad: Visibilidad = .privado
}
enum Action: Equatable {
case nombreCambiado(String)
case apellidosCambiados(String)
case correoCambiado(String)
case biografiaCambiada(String)
case notificacionesCambiadas(Bool)
case visibilidadCambiada(Visibilidad)
case guardarPulsado
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .nombreCambiado(v): state.nombre = v; return .none
case let .apellidosCambiados(v): state.apellidos = v; return .none
case let .correoCambiado(v): state.correo = v; return .none
case let .biografiaCambiada(v): state.biografia = v; return .none
case let .notificacionesCambiadas(v): state.notificaciones = v; return .none
case let .visibilidadCambiada(v): state.visibilidad = v; return .none
case .guardarPulsado: return .none
}
}
}
}
En la vista, cada control necesita fabricar a mano un Binding que lea del estado y escriba enviando su acción, porque un control de SwiftUI no acepta otra cosa.
TextField("Nombre", text: Binding(
get: { store.nombre },
set: { store.send(.nombreCambiado($0)) }
))
Seis campos son seis casos, seis ramas, seis bindings artesanales y —si el proyecto exige cobertura— seis tests que afirman que asignar un valor asigna ese valor. Añadir un campo toca cuatro sitios. Renombrarlo, cuatro. El coste no es la primera vez: es que crece linealmente y nunca compra nada.
Cuenta los casos de tu enum Action y clasifícalos en dos montones: los que responden a la pregunta qué ha ocurrido y los que responden a qué valor hay que escribir. Cuando el segundo montón supera al primero, el tipo ha dejado de ser el catálogo de sucesos de la feature y se ha convertido en un espejo redundante de la struct de estado. Ese es el umbral operativo a partir del cual la solución de este nivel deja de ser una comodidad y pasa a ser necesaria.
Las tres salidas falsas
Antes de aceptar la propuesta de TCA conviene entender por qué fracasan las alternativas que todo el mundo intenta primero, porque cada fracaso ilumina una restricción que la solución buena debe respetar.
La primera es mutar el estado desde la vista, exponiendo un binding que escriba en store.state directamente. Esto compila en algunas variantes y destruye la propiedad central de la arquitectura: aparece una segunda puerta de escritura que ni la traza ni TestStore ven, y a partir de ahí ningún test demuestra lo que cree demostrar, porque el estado puede haber cambiado sin que ninguna acción lo cuente.
La segunda es un único caso genérico sin tipar, del estilo case establecer(String, Any), donde el primer componente nombra el campo. Colapsa la explosión, sí, pero paga el precio en la moneda más cara de Swift: pierde la comprobación de tipos, convierte los errores de nombre en fallos silenciosos en tiempo de ejecución y hace que la Action deje de ser Equatable de forma útil, con lo que las aserciones del TestStore se vuelven inservibles.
La tercera es mantener un modelo observable paralelo para el formulario y volcarlo al estado solo al pulsar guardar. Funciona hasta que aparece el primer requisito real —validar mientras se escribe, habilitar el botón según el contenido, precargar desde red— y entonces hay dos fuentes de verdad que hay que sincronizar a mano, que es exactamente el problema que la arquitectura existía para eliminar.
flowchart TD P[Formulario con N campos] --> A[N casos en el enum] P --> B[N ramas en el switch] P --> C[N bindings artesanales] A --> D[Action deja de ser catalogo de sucesos] B --> D C --> D D --> S1[Salida falsa: mutar desde la vista] D --> S2[Salida falsa: caso generico sin tipar] D --> S3[Salida falsa: modelo paralelo] S1 --> X[Rompe traza y tests] S2 --> X S3 --> X D --> OK[Caso parametrico con key path tipado] style OK fill:#a6e3a1,color:#11111b
La forma de la solución
Las tres salidas falsas comparten un diagnóstico: intentan reducir el número de casos eliminando información. La propuesta de TCA hace lo contrario —conserva toda la información y la mueve del nombre del caso a su carga—. En lugar de que el caso se llame nombreCambiado y transporte un String, existe un solo caso llamado binding que transporta un valor capaz de decir, con tipos verificados, dos cosas: qué propiedad del estado hay que escribir y con qué valor.
enum Action: BindableAction {
case binding(BindingAction<State>)
case guardarPulsado
}
Ese valor empaqueta un key path escribible hacia una propiedad del State junto con el nuevo contenido, y sabe aplicarse a sí mismo sobre una instancia del estado. La pieza que lo aplica es un reducer dedicado que se coloca en el body y que, al recibir el caso binding, ejecuta la escritura sin que tú redactes una sola asignación. Los seis casos se convierten en uno, las seis ramas en ninguna, y los seis bindings artesanales en seis expresiones de la forma $store.campo que la vista obtiene gratis.
Un caso, no N
El enum recupera su función de inventario de sucesos con significado. Añadir un campo al estado ya no toca la Action.
Tipado, no dinámico
El key path es escribible y está comprobado por el compilador. No hay cadenas mágicas ni conversiones a Any.
Una sola puerta
La escritura sigue pasando por el reducer, así que la traza y el TestStore siguen viendo absolutamente todo.
Coste marginal cero
El sexto campo cuesta lo mismo que el primero: una línea en el State y una línea en la vista.
La incomodidad del formulario no es accidental: es el punto donde dos gramáticas legítimas de la arquitectura chocan. Una acción, tal como se ha enseñado hasta ahora, es el testimonio de un hecho —algo ocurrió, alguien lo presenció, el reducer delibera qué significa—. Un formulario no genera hechos en ese sentido; genera órdenes de escritura triviales cuya deliberación es vacía por construcción, porque la única respuesta razonable a el usuario escribió una letra más en el campo de correo es guardarla. Cuando obligas a las órdenes a vestirse de mensajes, pagas el precio completo del ceremonial —un símbolo, una rama, un test— por un contenido semántico nulo, y el tipo que debía documentar el comportamiento de la feature acaba documentando la forma de su estado, que ya estaba documentada por el propio estado. La solución de TCA es elegante precisamente porque no niega la distinción sino que la reifica: crea una categoría gramatical distinta para las órdenes, con su símbolo único y su reducer propio, y deja el resto del enum limpio para los hechos. El detalle profundo es que esta separación no debilita ninguna garantía, porque la orden sigue viajando como una acción —sigue apareciendo en la traza, sigue siendo interceptable, sigue pasando por el único punto de mutación—; lo único que cambia es que su identidad concreta vive en la carga y no en el nombre. Cuando en las próximas lecciones veas que se puede filtrar por el campo afectado, entenderás que la información nunca se perdió: solo se movió al lugar donde su multiplicidad no se traduce en multiplicación de código.
- Elige la pantalla con más campos editables de tu proyecto y cuenta exactamente cuántos casos, ramas del
switchy tests dedica hoy a puras asignaciones. - Calcula la razón entre setters y sucesos reales de esa
Actiony anótala: será tu métrica de antes y después al terminar el nivel. - Localiza cada
Bindingconstruido a mano congetysety suma las líneas que ocupan. Ese es el código que va a desaparecer. - Añade deliberadamente un campo nuevo al estado y anota cuántos ficheros y cuántos sitios tienes que tocar para que compile y funcione.
- Escribe en una línea qué garantía concreta perdería tu feature si permitieras a la vista mutar el estado sin pasar por una acción. Guárdala: es el criterio con el que juzgarás la solución.