wandres.dev
BINDINGS Y SWIFTUI · formularios

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.

⏱ 18 min

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.

🎯 Al terminar esta lección sabrás
  • Explicar qué exige el protocolo BindableAction y qué información empaqueta realmente un valor BindingAction.
  • Colocar BindingReducer en el body y justificar por qué su posición relativa al Reduce determina lo que ve tu lógica.
  • Conservar Equatable y trazabilidad en un formulario colapsado, y saber cómo aparece una edición en un test.
  • Dirigir el BindingReducer a un subenum cuando la Action está 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.

ℹ️
Por qué el key path debe ser escribible y por qué eso importa

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.

💡
Cómo se ve una edición en un test

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.

La clave del truco es reificar el acceso: convertir la ruta a un campo en un valor que puede viajar

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.

⚔️ Colapsa una Action y demuestra que no perdiste nada
  1. Toma la feature de formulario que midieron en la lección anterior y sustituye todos los setters por un único caso binding, adoptando BindableAction.
  2. Coloca BindingReducer() al principio del body y borra todas las ramas que solo hacían asignaciones. Comprueba que el switch sigue siendo exhaustivo.
  3. 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.
  4. Escribe un test que afirme sobre la escritura de un campo concreto y comprueba que la exhaustividad del TestStore sigue exigiéndote declarar la mutación.
  5. Parte la Action en subenums por origen, mueve el caso binding al de vista y dirige el reducer con BindingReducer(action: \.view) sin cambiar ningún test de comportamiento.