BindableAction y BindingReducer: la acción genérica y el reducer que la aplica
La solución de TCA a la explosión de setters descansa sobre dos piezas que conviene entender por separado antes de usarlas juntas. BindableAction es el protocolo que obliga a la Action a exponer un caso capaz de transportar una escritura tipada sobre el estado, y BindingAction es el valor que empaqueta el key path escribible y el nuevo contenido dentro de un tipo comparable e imprimible. BindingReducer es la hoja que, colocada en el body, reconoce ese caso y ejecuta la asignación. Esta lección disecciona ambas, explica por qué el orden en el body no es negociable, y muestra cómo se dirigen a un subenum cuando la Action está particionada.
El truco que colapsa doce casos en uno no es magia de macros: es una aplicación disciplinada de dos ideas que Swift ya tenía. La primera es que un key path escribible es un valor de primera clase, así que la identidad del campo a escribir puede viajar dentro de una acción en lugar de codificarse en su nombre. La segunda es que un reducer es un valor componible, así que la lógica de aplicar esa escritura puede vivir en una hoja reutilizable colocada al principio del body. BindableAction formaliza la primera, BindingReducer implementa la segunda, y de su encaje sale una feature donde añadir un campo al formulario no toca el enum ni el switch.
- Explicar qué exige el protocolo
BindableActiony qué información empaqueta realmente un valorBindingAction. - Colocar
BindingReduceren elbodyy justificar por qué su posición relativa alReducedetermina lo que ve tu lógica. - Conservar
Equatabley trazabilidad en un formulario colapsado, y saber cómo aparece una edición en un test. - Dirigir el
BindingReducera un subenum cuando laActionestá particionada por origen.
El protocolo y el valor que transporta
BindableAction es un protocolo con un único requisito: el tipo debe ofrecer una manera de construirse y descomponerse a partir de una escritura sobre el estado. En la práctica se satisface declarando un caso llamado binding cuya carga es un BindingAction parametrizado por el State de la feature.
@Reducer
struct Perfil {
@ObservableState
struct State: Equatable {
var nombre = ""
var correo = ""
var notificaciones = false
var visibilidad: Visibilidad = .privado
}
enum Action: BindableAction, Equatable {
case binding(BindingAction<State>)
case guardarPulsado
}
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .binding:
return .none
case .guardarPulsado:
return .run { [datos = state] _ in try await api.guardar(datos) }
}
}
}
}
Lo que hay dentro de un BindingAction no es un par de valores sueltos sino una escritura encapsulada: un key path escribible desde el State hasta una propiedad concreta y el nuevo valor de esa propiedad, ambos capturados con sus tipos exactos y ocultos tras una clausura que sabe aplicarse sobre una instancia. El tipo compuesto sigue siendo Equatable —compara el key path y el valor— y sigue siendo imprimible, de modo que las dos garantías que hacían útil un enum con nombres explícitos sobreviven intactas: puedes afirmar sobre una edición concreta en un test y puedes verla identificada en la traza.
La firma exige un key path de escritura, no de solo lectura. Esa restricción, que parece burocrática, es lo que impide que un binding apunte a una propiedad calculada o a un valor derivado: si el destino no es almacenado, no hay dónde escribir y el compilador lo rechaza antes de que el error llegue a ejecución. En la lección de validación aprovecharemos justamente el reverso de esta regla, exponiendo lo derivado como propiedad calculada precisamente para que nadie pueda escribirlo por accidente.
El orden en el body no es cosmético
BindingReducer es una hoja como cualquier otra: recibe estado y acción, y devuelve un efecto. Su cuerpo hace una sola cosa —si la acción es el caso binding, aplicar la escritura que transporta— y para todo lo demás devuelve .none. Como los reducers de un body se ejecutan en secuencia sobre el mismo estado, su posición decide qué ve el reducer siguiente.
// Correcto: cuando tu Reduce corre, el valor nuevo YA esta en el estado
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .binding(\.correo):
state.correoValido = state.correo.contains("@")
return .none
default:
return .none
}
}
}
// Incorrecto: tu Reduce lee el valor ANTERIOR, la escritura aun no ocurrio
var body: some ReducerOf<Self> {
Reduce { state, action in /* ... */ }
BindingReducer()
}
Con BindingReducer primero, tu lógica observa el estado ya actualizado y puede razonar sobre él con naturalidad: comprobar la longitud del texto recién escrito, decidir si el formulario está completo, disparar una búsqueda con el término actual. Con el orden invertido lees el valor previo, lo cual no es un error de compilación sino una discrepancia de un tick que se manifiesta como una validación que siempre va un carácter por detrás. Es uno de los fallos más desconcertantes de este nivel porque todo parece correcto y el síntoma es sutil.
| Colocación | Lo que ve tu Reduce |
Consecuencia típica |
|---|---|---|
BindingReducer antes |
El valor nuevo, ya escrito | Validación y efectos coherentes |
BindingReducer después |
El valor anterior | Retraso sistemático de un carácter |
sequenceDiagram participant V as Vista participant S as Store participant BR as BindingReducer participant R as Reduce propio V->>S: send binding con key path y valor S->>BR: entrega la accion BR->>BR: escribe la propiedad del estado BR->>S: devuelve none S->>R: entrega la misma accion R->>R: lee el estado ya actualizado R->>S: devuelve efecto o none
Cuando la Action está particionada
En features maduras la Action suele estar dividida por origen, con un subenum para lo que la vista puede enviar y otros para lo interno y lo delegado. El caso binding pertenece conceptualmente a la vista, así que vive dentro de ese subenum, y el BindingReducer necesita saber dónde buscarlo. Para eso acepta un key path que lo dirige a la rama correcta.
enum Action: ViewAction {
case view(View)
case _internal(Internal)
enum View: BindableAction, Equatable {
case binding(BindingAction<State>)
case guardarPulsado
}
enum Internal: Equatable {
case guardadoTermino(Bool)
}
}
var body: some ReducerOf<Self> {
BindingReducer(action: \.view)
Reduce { state, action in /* ... */ }
}
El subenum conforma BindableAction y el BindingReducer se apunta a él. La regla mental es simple: el reducer de binding se coloca en el mismo nivel donde vive el State que va a escribir, y el key path indica el camino hasta el caso que lo transporta. Si el enum externo no conforma el protocolo, ningún compilador te obliga a nada raro: basta con que el subenum lo haga y con que el BindingReducer reciba la ruta.
En TestStore la afirmación se escribe con la misma forma que tendría un setter con nombre, pero apuntando a la propiedad: envías la escritura del campo y declaras la mutación esperada. La ergonomía es idéntica a la de cualquier otra acción y la exhaustividad se mantiene, así que un formulario colapsado no pierde ni un gramo de cobertura; simplemente deja de necesitar un test por campo para probar que la asignación funciona, porque eso ya lo prueba el framework una sola vez para todos.
Lo que hace posible colapsar doce casos en uno no es una comodidad de biblioteca sino una propiedad estructural del lenguaje que TCA explota hasta el fondo: en Swift, la ruta hasta una propiedad es un valor. Un key path escribible es la reificación de un acceso, es decir, la operación llegar a este campo y poder escribir en él convertida en un dato de primera clase que se puede almacenar, comparar, pasar como argumento y guardar dentro de una acción. Sin esa reificación, la única manera de identificar un campo dentro de un mensaje sería nombrarlo, y nombrar en un sistema de tipos significa crear un símbolo distinto por cada nombre: de ahí la explosión. Con ella, la identidad del campo se convierte en carga, y la cardinalidad del enum se desacopla por completo de la cardinalidad del estado. Esta es la misma jugada conceptual que hacen los case paths para el lado de las acciones y que hace Scope para la composición de features: sustituir la enumeración manual de casos por un valor que representa el acceso. Y el segundo ingrediente es igual de importante: como un reducer es también un valor componible, la lógica de aplicar la escritura puede empaquetarse en una hoja reutilizable en vez de duplicarse en cada switch. El resultado combinado es que la mutación de formularios deja de ser código que escribes y pasa a ser código que compones, con la consecuencia práctica de que su corrección se demuestra una vez en la biblioteca en lugar de auditarse doce veces en tu proyecto. Cuando alguien dice que TCA es verboso, casi siempre está describiendo un formulario escrito sin estas dos piezas.
- Toma la feature de formulario que midieron en la lección anterior y sustituye todos los setters por un único caso
binding, adoptandoBindableAction. - Coloca
BindingReducer()al principio delbodyy borra todas las ramas que solo hacían asignaciones. Comprueba que elswitchsigue siendo exhaustivo. - Invierte deliberadamente el orden del
body, añade una línea que lea un campo recién editado y observa el retraso de un carácter. Vuelve a dejarlo bien. - Escribe un test que afirme sobre la escritura de un campo concreto y comprueba que la exhaustividad del
TestStoresigue exigiéndote declarar la mutación. - Parte la
Actionen subenums por origen, mueve el casobindingal de vista y dirige el reducer conBindingReducer(action: \.view)sin cambiar ningún test de comportamiento.