wandres.dev
BINDINGS Y SWIFTUI · formularios

Reaccionar a un campo concreto: interceptar el binding para validar o disparar efectos

Colapsar los setters en un solo caso plantea la objeción evidente: si todas las ediciones comparten símbolo, cómo se ejecuta lógica cuando cambia un campo determinado. La respuesta es que la información no se perdió, solo se movió a la carga, y el patrón de coincidencia por key path la recupera con precisión total. Esta lección enseña a filtrar por campo dentro del caso binding, a capturar el valor anterior cuando la lógica lo necesita, a devolver efectos desde una edición con la cancelación y el debounce que exige teclear, y a reconocer el punto en que un campo deja de merecer un binding y reclama su propio caso con nombre.

⏱ 19 min

La objeción llega siempre en el mismo momento: si todos los campos envían la misma acción, se ha perdido la capacidad de reaccionar a uno en particular. Es falsa, y entender por qué es falsa es entender qué se hizo realmente en las lecciones anteriores. La identidad del campo no desapareció al colapsar los casos; se mudó del nombre del símbolo a la carga del mensaje, donde sigue siendo un valor tipado, comparable y —esto es lo que faltaba— coincidible por patrón. Filtrar por campo dentro del caso binding no solo recupera todo lo que parecía perdido, sino que lo hace mejor: el filtro es explícito, aparece solo donde hay lógica de verdad, y los campos que no la tienen no ensucian el switch con ramas vacías.

🎯 Al terminar esta lección sabrás
  • Coincidir por key path dentro del caso binding para ejecutar lógica solo cuando cambia un campo concreto.
  • Capturar el valor anterior de una propiedad cuando la reacción depende de la transición y no solo del valor nuevo.
  • Devolver efectos desde una edición aplicando debounce con reloj inyectado y cancelación de la petición en vuelo.
  • Decidir con criterio cuándo un campo ha dejado de ser un binding y necesita un caso con nombre propio.

Filtrar por key path

El caso binding admite coincidencia de patrón con el key path del campo afectado. La rama específica se coloca antes de la genérica, y el compilador sigue exigiendo exhaustividad, de modo que ninguna edición queda sin tratar.

var body: some ReducerOf<Self> {
  BindingReducer()
  Reduce { state, action in
    switch action {
    case .binding(\.correo):
      state.correoValido = Validador.correo(state.correo)
      return .none

    case .binding(\.notificaciones):
      return .run { [activo = state.notificaciones] _ in
        await self.notificaciones.actualizar(activo)
      }

    case .binding(\.pais):
      state.provincia = nil
      state.provincias = []
      return .run { [pais = state.pais] send in
        await send(.provinciasLlegaron(try await self.api.provincias(pais)))
      }

    case .binding:
      return .none

    case let .provinciasLlegaron(lista):
      state.provincias = lista
      return .none
    }
  }
}

Tres reacciones de naturalezas distintas conviven sin fricción. La primera es pura: deriva un valor a partir del recién escrito. La segunda es un efecto sin respuesta: notifica al mundo exterior y no espera nada. La tercera es la más interesante porque combina las dos y añade una invalidación en cascada: al cambiar el país, la provincia elegida deja de tener sentido y se limpia en el mismo tick, antes de que la petición vuelva. Ese saneamiento inmediato es lo que evita el estado incoherente que aparecería si esperaras a la respuesta para limpiar.

Recuerda que todo esto solo funciona con BindingReducer colocado antes: cuando tu rama corre, state.correo ya contiene la letra que el usuario acaba de teclear. Si lees el valor anterior, revisa el orden del body antes de sospechar de cualquier otra cosa.

ℹ️
Cuando la reacción depende de la transición, no del valor

Filtrar por key path te da el valor nuevo, porque el estado ya está escrito. Si tu lógica necesita el anterior —detectar que un interruptor pasó de apagado a encendido, y no simplemente que está encendido— tienes dos caminos limpios. El primero es capturarlo antes de que el BindingReducer actúe, colocando un Reduce de solo lectura delante que guarde la instantánea en un campo del estado. El segundo, casi siempre mejor, es admitir que lo que te importa es la transición y no la escritura: eso es un suceso con nombre, y le corresponde un caso propio.

Efectos disparados por tecleo

El caso más común y más delicado es una edición que dispara una petición: un buscador que consulta mientras se escribe, un campo de usuario que comprueba disponibilidad, un formulario que autoguarda. Aquí el binding se encuentra de frente con todo lo aprendido sobre efectos, porque teclear genera decenas de acciones por segundo y cada una podría abrir una petición.

private enum CancelID { case busqueda }

case .binding(\.termino):
  guard state.termino.count >= 3 else {
    state.resultados = []
    return .cancel(id: CancelID.busqueda)
  }
  return .run { [termino = state.termino] send in
    await send(.resultadosLlegaron(try await self.api.buscar(termino)))
  }
  .debounce(id: CancelID.busqueda, for: .milliseconds(300), scheduler: self.reloj)

Cada pieza responde a un fallo concreto. El guardia de longitud mínima evita disparar contra el servidor con una sola letra y, sobre todo, cancela explícitamente lo que hubiera en vuelo cuando el usuario borra hasta quedar por debajo del umbral: sin ese cancel, una respuesta tardía repoblaría una lista que debía estar vacía. El debounce con identificador estable convierte una ráfaga de pulsaciones en una sola petición tras la pausa, y como comparte identificador con la cancelación, ambas operan sobre el mismo canal. El reloj es una dependencia inyectada, de modo que el test avanza el tiempo en lugar de esperarlo y la prueba tarda milisegundos.

flowchart TD
T[Tecla en el campo] --> BA[Accion binding con key path]
BA --> BR[BindingReducer escribe el estado]
BR --> SW[Coincidencia por key path]
SW -->|campo sin logica| N[none]
SW -->|campo derivado| D[Actualizar estado derivado]
SW -->|campo con dependencia| C[Limpiar cascada y pedir]
SW -->|campo de busqueda| E[Debounce y cancelacion]
E --> R[Respuesta llega como accion propia]
C --> R
R --> ST[Estado coherente]
D --> ST
style ST fill:#a6e3a1,color:#11111b

El límite: cuándo el campo reclama su propio caso

El patrón de intercepción es tan cómodo que invita a abusar de él. Conviene fijar el criterio antes de que el abuso se instale, y el criterio es semántico, no técnico.

Un binding es correcto cuando la interacción significa literalmente el usuario dejó este campo con este valor. Deja de serlo en cuanto el gesto significa algo más: un interruptor que arranca una migración de datos, un selector que reinicia una sesión, una casilla que acepta condiciones legales. En esos casos la traza es el árbitro. Si al leer el registro de acciones necesitas saber qué ocurrió y ver una escritura de campo no te lo dice, ese gesto merecía un nombre.

// Binding legitimo: el usuario esta editando un valor
case .binding(\.nombre):
  return .none

// Mal olor: el gesto significa mucho mas que escribir un valor
case .binding(\.modoAvanzado):
  return .run { _ in await self.migrador.reconstruirIndice() }

// Mejor: el suceso tiene nombre y la traza lo cuenta
case .modoAvanzadoActivado:
  state.modoAvanzado = true
  return .run { _ in await self.migrador.reconstruirIndice() }

Hay además un aviso de rendimiento: una rama de binding que hace trabajo caro se ejecuta en cada pulsación de tecla. Filtrar por longitud, aplicar debounce y mantener el trabajo síncrono trivial no son optimizaciones prematuras en este punto concreto, son la condición para que el formulario no vaya a tirones.

🎯

Filtra solo lo que te importa

Una rama por campo con lógica y una genérica para el resto. Los campos mudos no aparecen en tu código.

🧹

Sanea en el mismo tick

Si un campo invalida a otro, límpialo al instante. Esperar a la respuesta deja el estado incoherente por medio.

⏱️

Teclear no es pedir

Umbral de longitud, debounce con reloj inyectado y cancelación explícita comparten identificador y se coordinan.

🏷️

Si significa más, ponle nombre

Cuando la traza no explica qué ocurrió, el gesto merecía un caso propio en pasado y no una escritura de campo.

La coincidencia por key path demuestra que colapsar casos no fue perder expresividad sino cambiar de eje

Cuando se sustituyen doce casos con nombre por uno paramétrico, la sospecha natural es que se ha ganado brevedad a costa de precisión, como si el tipo se hubiera vuelto más tosco. La intercepción por key path demuestra que ocurrió lo contrario, y el argumento merece formularse con cuidado porque es el corazón intelectual del nivel. En la versión con doce casos, la distinción entre campos estaba codificada en la estructura del tipo: doce símbolos, doce ramas obligatorias, exhaustividad impuesta sobre todos ellos aunque once no tuvieran nada que decir. Esa codificación tiene una propiedad indeseable: el coste de mantener la distinción es proporcional al número de distinciones posibles, no al número de distinciones que a tu lógica le importan. En la versión colapsada, la distinción vive en un valor que la acción transporta, y el patrón de coincidencia te permite pagar solo por las que usas: escribes una rama para el campo que dispara una búsqueda, otra para el que invalida una cascada, y los diez restantes no aparecen en tu código porque, honestamente, tu lógica no tiene nada que decir sobre ellos. La expresividad no bajó; se hizo proporcional al interés. Y el detalle que cierra el argumento es que la seguridad no se relajó en ningún punto: el key path del patrón está comprobado por el compilador contra el tipo del estado, así que renombrar un campo rompe la rama que lo intercepta exactamente igual que rompería un caso con nombre, y el switch sigue exigiendo exhaustividad mediante la rama genérica. Se eliminó la ceremonia sin eliminar ninguna de las garantías que la ceremonia servía para obtener, que es la definición operativa de una buena abstracción.

⚔️ Interviene un campo sin ensuciar el resto
  1. Localiza en tu formulario el campo que dispara trabajo al editarse e intercéptalo con una rama case .binding(\.campo), comprobando que las demás ediciones siguen cayendo en la rama genérica.
  2. Añade un campo cuya edición invalide otro en cascada y verifica que la limpieza ocurre en el mismo tick, antes de que llegue ninguna respuesta.
  3. Convierte una búsqueda por tecleo en un efecto con debounce, umbral mínimo de longitud y cancel explícito al bajar del umbral. Comprueba con la traza que una ráfaga produce una sola petición.
  4. Escribe el test correspondiente con reloj inyectado: avanza el tiempo en lugar de esperarlo y afirma que solo llega una respuesta.
  5. Revisa cada rama de binding con lógica y pregúntate si el gesto significa más que escribir un valor. Devuelve al menos uno a un caso con nombre en pasado y comprueba cómo mejora la traza.