wandres.dev
MODELAR LA ACTION · enums que cuentan qué pasó

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.

⏱ 18 min

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.

🎯 Al terminar esta lección sabrás
  • Colapsar los setters de un formulario en un único caso con BindableAction y BindingReducer.
  • 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 ViewAction y 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.

💡
Reaccionar a un campo concreto sin renunciar al binding

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 }
}
⚠️
Un binding no es un sustituto universal del caso con nombre

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.

Los dos protocolos atacan las dos patologías simétricas de un enum de acciones

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.

⚔️ Sella y adelgaza la Action de un formulario
  1. Toma una pantalla con al menos cinco campos editables y cuenta cuántos casos y cuántas ramas dedica hoy a simples asignaciones.
  2. Adopta BindableAction y BindingReducer, sustituye los setters por @Bindable en la vista y comprueba que el comportamiento no cambia.
  3. 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.
  4. Parte la Action con un subenum View, adopta ViewAction y la macro en la vista, e intenta enviar deliberadamente una acción interna: debe fallar la compilación.
  5. 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.