BindableAction y ViewAction: los dos protocolos que disciplinan la vista
Un formulario con doce campos no debería costar doce casos de enum, doce ramas de switch y doce tests triviales; y una vista no debería poder enviar acciones que solo tienen sentido para los efectos internos de la feature. TCA resuelve ambos problemas con dos protocolos complementarios: BindableAction, que colapsa toda la mutación directa de campos en un único caso binding gobernado por BindingReducer, y ViewAction, que junto a la macro ViewAction para la vista convierte en error de compilación cualquier envío fuera del subenum autorizado. Esta lección los estudia por separado, muestra cómo se combinan en la misma feature y advierte de los casos en que un binding es la herramienta equivocada.
Dos fuerzas tiran de la Action en direcciones opuestas. Los formularios empujan hacia la explosión: cada campo de texto, cada interruptor y cada deslizador reclama su propio caso, su rama en el switch y su test, y todos hacen exactamente lo mismo —asignar un valor a un campo—. La composición empuja hacia la protección: cuanto más rica es la Action, más casos existen que la vista jamás debería enviar y que, sin embargo, puede enviar. TCA responde a cada fuerza con un protocolo. BindableAction colapsa la explosión de setters en un único caso, y ViewAction sella la superficie que la interfaz alcanza. Son ortogonales, se usan juntos y resuelven problemas que ninguna otra parte de la arquitectura resuelve.
- Colapsar los setters de un formulario en un único caso con
BindableActionyBindingReducer. - Reaccionar a la edición de un campo concreto usando un case path dentro del caso
binding. - Restringir por compilador lo que la vista puede enviar mediante
ViewActiony la macro homónima. - Combinar ambos protocolos en la misma feature y reconocer cuándo un binding es la herramienta equivocada.
El coste oculto de un formulario
Escrito a mano, un formulario de tres campos produce tres casos idénticos en intención y distintos en tipo, tres ramas de una sola línea y tres tests que no verifican ninguna regla de negocio. Con diez campos, la Action deja de leerse como el catálogo de sucesos de la feature y pasa a leerse como la lista de propiedades de la struct, duplicada.
BindableAction sustituye todo eso por un único caso paramétrico que transporta, dentro de sí, el key path del campo afectado y su nuevo valor. BindingReducer es la hoja que aplica esa escritura sin que tú escribas una sola asignación.
@Reducer
struct Ajustes {
@ObservableState
struct State: Equatable {
var nombre = ""
var notificaciones = false
var volumen = 0.5
}
enum Action: BindableAction {
case binding(BindingAction<State>)
case guardarPulsado
}
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .binding(\.notificaciones):
return .run { [on = state.notificaciones] _ in
await notificaciones.actualizar(on)
}
case .binding:
return .none
case .guardarPulsado:
return .none
}
}
}
}
En la vista, @Bindable produce bindings directamente contra el Store, y cada escritura se traduce internamente en un envío del caso binding.
struct AjustesView: View {
@Bindable var store: StoreOf<Ajustes>
var body: some View {
Form {
TextField("Nombre", text: $store.nombre)
Toggle("Notificaciones", isOn: $store.notificaciones)
Slider(value: $store.volumen)
}
}
}
Lo notable es que la disciplina unidireccional no se rompe: el binding no muta el estado, envía una acción que el BindingReducer interpreta. La mutación sigue pasando por el único punto autorizado, y _printChanges sigue registrando cada edición como un suceso con nombre.
El patrón case .binding(\.notificaciones) filtra por key path y permite ejecutar lógica solo cuando ese campo cambia, conservando el caso genérico para el resto. Coloca siempre BindingReducer() antes del Reduce, de modo que cuando tu rama se ejecute el valor nuevo ya esté escrito en el estado. En iOS 16 y anteriores, sustituye @Bindable por @Perception.Bindable y envuelve el cuerpo en WithPerceptionTracking.
ViewAction: cerrar por compilador la puerta de la vista
La partición en view, _internal y delegate de la lección anterior es una convención que nada obliga a respetar: la vista puede seguir enviando ._internal(...) si le apetece. ViewAction convierte esa convención en una restricción verificada. El enum Action declara conformidad al protocolo y expone un caso view con su subenum; la macro @ViewAction(for:) dota a la vista de un método send que solo acepta valores de ese subenum.
@Reducer
struct Contador {
@ObservableState
struct State: Equatable { var cuenta = 0 }
enum Action: ViewAction {
case view(View)
case _internal(Internal)
enum View: Equatable {
case incrementarPulsado
case reiniciarPulsado
}
enum Internal: Equatable {
case tickRecibido
}
}
}
@ViewAction(for: Contador.self)
struct ContadorView: View {
let store: StoreOf<Contador>
var body: some View {
VStack {
Text("\(store.cuenta)")
Button("Mas") { send(.incrementarPulsado) }
Button("Reiniciar") { send(.reiniciarPulsado) }
}
}
}
Escribir send(.tickRecibido) deja de compilar, y con ello desaparece toda una familia de errores: la vista que simula una respuesta de red, la que se salta la validación disparando directamente la acción interna que sigue al gesto, la que reenvía una delegate para provocar navegación desde abajo. Ninguna de esas rutas necesita ya vigilancia en revisión de código porque el tipo las prohíbe.
flowchart LR F[Campos del formulario] --> B[caso binding] B --> BR[BindingReducer escribe] G[Gestos con logica] --> VA[subenum View] VA --> RD[Reduce delibera] BR --> ST[State] RD --> ST RD --> EF[Effect]
Los dos juntos, y cuándo no usar binding
Nada impide combinarlos: la Action conforma ambos protocolos, el caso binding se declara dentro del subenum View —que a su vez conforma BindableAction— y el BindingReducer se apunta a esa rama con BindingReducer(action: \.view). El resultado es una feature donde los campos del formulario cuestan cero casos y la vista no puede enviar nada más que sus propios gestos.
enum Action: BindableAction, ViewAction {
case view(View)
case _internal(Internal)
enum View: BindableAction, Equatable {
case binding(BindingAction<State>)
case guardarPulsado
}
enum Internal: Equatable { case guardadoTerminado(Bool) }
}
var body: some ReducerOf<Self> {
BindingReducer(action: \.view)
Reduce { state, action in /* ... */ .none }
}
BindingAction es apropiado cuando la interacción es literalmente escribir un valor en un campo. Deja de serlo en cuanto el gesto significa algo más: pulsar un interruptor que dispara una migración, elegir una opción que reinicia una búsqueda, o cualquier interacción cuya semántica no sea el usuario editó este campo. En esos casos vuelve al caso con nombre en pasado, porque el nombre es la única documentación de que ahí ocurre algo. Regla operativa: si al leer la traza necesitas saber qué pasó y binding no te lo dice, es que ese gesto merecía su propio caso.
Un enum Action puede enfermar de dos maneras opuestas y BindableAction y ViewAction son, exactamente, los antídotos de cada una. La primera patología es la hipertrofia por ruido: casos que existen no porque haya ocurrido algo significativo sino porque la mecánica de la interfaz exige un canal, y que ahogan los sucesos que sí importan bajo docenas de setters indistinguibles. Cuando un enum tiene cuarenta casos y treinta son asignaciones, el tipo ha dejado de ser el catálogo de lo que puede pasarle a la feature y se ha convertido en un espejo redundante de la struct de estado; BindableAction colapsa esa masa en un solo caso paramétrico que conserva toda la información —qué campo, qué valor— en un único símbolo, y devuelve al enum su función original de inventario de sucesos con significado. La segunda patología es la permisividad estructural: todos los casos accesibles para todos los emisores, incluida una vista que solo debería testificar gestos y que, sin embargo, puede fabricar respuestas de red inexistentes o disparar delegaciones que la feature nunca produjo. Esa permisividad no rompe nada el primer día pero corroe la garantía central de la arquitectura, porque un test que confía en que las acciones internas nacen de efectos deja de demostrar lo que cree demostrar en cuanto alguien las envía desde un botón. ViewAction cierra esa puerta con la única llave que no se olvida, el compilador. Lo elegante es que ambos protocolos operan sobre el mismo eje —la relación entre la vista y el conjunto de acciones— desde extremos contrarios: uno reduce lo que la vista necesita nombrar, el otro reduce lo que la vista tiene derecho a nombrar. Aplicados juntos, la Action que queda es pequeña, expresiva y sellada: cada caso significa algo, y solo lo envía quien puede haberlo presenciado.
- Toma una pantalla con al menos cinco campos editables y cuenta cuántos casos y cuántas ramas dedica hoy a simples asignaciones.
- Adopta
BindableActionyBindingReducer, sustituye los setters por@Bindableen la vista y comprueba que el comportamiento no cambia. - Localiza el campo que sí requiere lógica al editarse y atiéndelo con
case .binding(\.campo), verificando que el valor ya está escrito cuando tu rama corre. - Parte la
Actioncon un subenumView, adoptaViewActiony la macro en la vista, e intenta enviar deliberadamente una acción interna: debe fallar la compilación. - Combina ambos con
BindingReducer(action: \.view)y revisa cada campo restante: si alguno significa más que este valor cambió, devuélvelo a un caso con nombre en pasado.