wandres.dev
BINDINGS Y SWIFTUI · formularios

Validación de formularios: estado derivado, errores por campo y envío

Un formulario terminado no es el que escribe en el estado sino el que sabe cuándo lo que contiene es enviable. Esta lección cierra el nivel construyendo la capa de validación sobre las piezas anteriores: propiedades calculadas que derivan la validez en lugar de campos almacenados que hay que recordar actualizar, errores por campo que solo se muestran cuando el usuario ya tuvo oportunidad de acertar, y un envío que bloquea el formulario, tolera el fallo del servidor y devuelve errores remotos al mismo canal por campo. Termina con la prueba de que un formulario colapsado se testea igual de bien que uno con doce setters, y bastante más rápido.

⏱ 20 min

Escribir en el estado era la mitad fácil. La difícil es responder a la pregunta que el usuario hace mirando el botón: ¿ya puedo enviar esto? Una respuesta ingenua guarda un campo booleano y lo actualiza en cada edición, y funciona hasta la primera vez que alguien añade un campo y olvida tocar el sitio donde se recalcula; a partir de ahí el formulario miente. La respuesta buena es que la validez no es un dato que se guarda sino una consecuencia que se deriva, y que el estado almacenado debe contener solo lo que el usuario escribió más el mínimo necesario para saber qué se le puede reprochar y cuándo. Esta lección construye esa capa y cierra el nivel enseñando el envío completo, con su bloqueo, su fallo y su vuelta.

🎯 Al terminar esta lección sabrás
  • Derivar la validez con propiedades calculadas en lugar de mantener banderas almacenadas que se desincronizan.
  • Modelar errores por campo y mostrarlos solo cuando el campo ha sido tocado o el envío ya se intentó.
  • Escribir el ciclo de envío completo: bloqueo, éxito, fallo y errores remotos devueltos al canal por campo.
  • Probar el formulario entero con TestStore afirmando sobre ediciones, validez derivada y resultado del envío.

La validez se deriva, no se guarda

La regla es la misma que rige todo el modelado de estado en TCA y aquí se paga con especial rapidez: si un valor puede calcularse a partir de otros, guardarlo es crear una segunda fuente de verdad que hay que mantener sincronizada a mano. Un formulario tiene muchas de esas oportunidades, así que la disciplina se nota.

@ObservableState
struct State: Equatable {
  var nombre = ""
  var correo = ""
  var edad = ""
  var aceptaTerminos = false
  var tocados: Set<Campo> = []
  var intentoEnvio = false
  var enviando = false
  var errorRemoto: [Campo: String] = [:]

  var errores: [Campo: String] {
    var e: [Campo: String] = [:]
    if nombre.trimmingCharacters(in: .whitespaces).isEmpty {
      e[.nombre] = "El nombre es obligatorio"
    }
    if !correo.contains("@") {
      e[.correo] = "Introduce un correo valido"
    }
    if Int(edad).map({ $0 < 18 }) ?? true {
      e[.edad] = "Debes ser mayor de edad"
    }
    if !aceptaTerminos {
      e[.terminos] = "Debes aceptar los terminos"
    }
    return e.merging(errorRemoto) { _, remoto in remoto }
  }

  var esValido: Bool { errores.isEmpty }
  var puedeEnviar: Bool { esValido && !enviando }

  func errorVisible(_ campo: Campo) -> String? {
    guard tocados.contains(campo) || intentoEnvio else { return nil }
    return errores[campo]
  }
}

Lo almacenado es exactamente lo que nadie puede deducir: lo que el usuario escribió, qué campos ha tocado, si ya intentó enviar, si hay un envío en curso y qué reprochó el servidor. Todo lo demás se calcula. La consecuencia práctica es que añadir un campo con su regla es una sola edición dentro de errores: el botón, los mensajes y los tests se actualizan solos porque todos leen de la misma derivación.

Las propiedades calculadas no participan en la igualdad sintetizada ni pueden ser destino de un binding, y ambas cosas son deseables aquí. La primera evita comparaciones redundantes; la segunda hace imposible por construcción que alguien intente escribir en esValido.

⚠️
El coste de derivar y cuándo deja de ser gratis

errores se recalcula en cada lectura, y la vista puede leerlo varias veces por fotograma. Con reglas locales y baratas eso es ruido. Si una regla implica recorrer una colección grande, normalizar texto costoso o consultar algo pesado, la derivación deja de ser inocua: entonces sí conviene un campo almacenado que el reducer actualiza en las ramas de binding pertinentes, asumiendo conscientemente la deuda de mantenerlo. La heurística es derivar por defecto y almacenar solo con una medición que lo justifique.

Errores por campo y el momento de mostrarlos

Un formulario que enseña el nombre es obligatorio antes de que el usuario haya podido escribir nada es hostil. La solución no es retrasar la validación —que corre siempre— sino separar el hecho de que un campo sea inválido del hecho de que ya sea justo reprochárselo.

case .binding(\.nombre):   state.tocados.insert(.nombre); state.errorRemoto[.nombre] = nil; return .none
case .binding(\.correo):   state.tocados.insert(.correo); state.errorRemoto[.correo] = nil; return .none
case .binding(\.edad):     state.tocados.insert(.edad);   state.errorRemoto[.edad] = nil;   return .none
case .binding:             return .none

Cada edición marca el campo como tocado y borra el reproche remoto que pudiera arrastrar, porque un error que el servidor devolvió sobre un valor anterior deja de aplicar en cuanto el valor cambia. Mantener ese error visible mientras el usuario lo corrige es una de las frustraciones más citadas en formularios reales.

En la vista, el mensaje cuelga del campo y el botón lee la derivación.

Section {
  TextField("Nombre", text: $store.nombre)
  if let e = store.errorVisible(.nombre) {
    Text(e).font(.caption).foregroundStyle(.red)
  }
  TextField("Correo", text: $store.correo)
  if let e = store.errorVisible(.correo) {
    Text(e).font(.caption).foregroundStyle(.red)
  }
}
Button("Crear cuenta") { store.send(.enviarPulsado) }
  .disabled(!store.puedeEnviar)
flowchart TD
ED[Edicion de campo] --> BR[BindingReducer escribe]
BR --> TOC[Marcar campo tocado y limpiar error remoto]
TOC --> DER[Recalcular errores derivados]
DER --> VIS{Campo tocado o envio intentado}
VIS -->|si| MSG[Mostrar mensaje bajo el campo]
VIS -->|no| OCU[Ocultar mensaje]
DER --> BTN[Habilitar boton si valido y sin envio en curso]
BTN --> ENV[Enviar]
ENV --> OK[Exito y delegate hacia arriba]
ENV --> KO[Fallo con errores por campo]
KO --> DER
style OK fill:#a6e3a1,color:#11111b

El envío y su vuelta

El botón habilitado no basta: el envío es un efecto con estados intermedios que el usuario debe percibir y con un fallo que hay que devolver al mismo lenguaje que ya usa el formulario.

case .enviarPulsado:
  state.intentoEnvio = true
  guard state.esValido else { return .none }
  state.enviando = true
  state.errorRemoto = [:]
  return .run { [datos = Alta(state)] send in
    await send(.envioTermino(Result { try await self.api.crearCuenta(datos) }))
  }

case .envioTermino(.success):
  state.enviando = false
  return .send(.delegate(.cuentaCreada))

case let .envioTermino(.failure(error)):
  state.enviando = false
  if let validacion = error as? ErrorDeValidacion {
    state.errorRemoto = validacion.porCampo
  } else {
    state.alerta = AlertState { TextState("No se pudo crear la cuenta") }
  }
  return .none

El intentoEnvio se marca antes del guardia para que, al pulsar con el formulario incompleto, aparezcan de golpe todos los mensajes pendientes aunque el usuario no hubiera tocado esos campos. El guardia protege contra el envío inválido incluso si alguien logra activar el botón por otra vía. El vaciado del error remoto evita mezclar reproches de dos intentos distintos. Y el fallo se bifurca: si el servidor habla el idioma de los campos, sus mensajes entran por el mismo canal que la validación local y aparecen exactamente donde el usuario los espera; si es un fallo genérico, se presenta como alerta porque no pertenece a ningún campo.

💡
Cómo se testea todo esto de una vez

El TestStore recibe las ediciones como escrituras de campo, y entre ellas puedes afirmar sobre la validez derivada sin declararla, porque una propiedad calculada no forma parte del estado a igualar. La secuencia típica es: escribir un campo inválido y comprobar que puedeEnviar sigue siendo falso, corregirlo, enviar, avanzar el reloj de la dependencia y afirmar sobre la acción de resultado. Un formulario de doce campos necesita un test de flujo, no doce tests de asignación, y esa es la ganancia práctica de todo el nivel medida en tiempo de suite.

Un formulario bien modelado es la prueba de estrés de toda la arquitectura, porque comprime en una pantalla los cuatro problemas del curso

Merece la pena mirar hacia atrás desde aquí, porque el formulario que acaba de construirse no es un ejercicio menor de interfaz: es el lugar donde las cuatro grandes tensiones de la arquitectura aparecen simultáneamente y en su forma más aguda. La primera es la tensión entre expresividad y ceremonia, que se resolvió reificando el acceso: la ruta a un campo pasó a ser un valor, y con eso la cardinalidad del tipo de acciones se desacopló de la cardinalidad del estado. La segunda es la tensión entre mutabilidad aparente y mutación controlada, que se resolvió con un binding cuya escritura no escribe sino que envía, conservando traza, interceptabilidad y testabilidad a través de una interfaz que finge ser una variable. La tercera es la tensión entre estado y consecuencia, que se resuelve aquí decidiendo qué se guarda y qué se calcula: lo escrito por el usuario y su historia de interacción se almacenan porque nadie puede deducirlos, mientras que la validez, la habilitación del botón y el conjunto de reproches se derivan, con lo que desaparece de raíz la clase entera de fallos en que el botón y el contenido discrepan. La cuarta es la tensión entre lo local y lo remoto, que se resuelve haciendo que el veredicto del servidor entre por el mismo canal que la validación propia, de modo que la vista no necesita saber de dónde vino el reproche para saber dónde ponerlo. Ninguna de estas cuatro resoluciones es específica de los formularios; todas son la aplicación al caso concreto de principios que llevan veinte niveles enunciándose. Lo que los formularios aportan es la compresión: en una sola pantalla, con doce campos y un botón, se puede comprobar si una arquitectura escala o solo predica. La que acaba de construirse escala, y la evidencia es que el campo número trece cuesta dos líneas —una en el estado, una en la vista— y una regla si la necesita, mientras que la traza, los tests y las garantías siguen exactamente donde estaban.

⚔️ Termina un formulario real de principio a fin
  1. Sustituye todas las banderas almacenadas de validez de tu formulario por propiedades calculadas y comprueba que el botón sigue comportándose igual, pero ya no puede desincronizarse.
  2. Añade el conjunto de campos tocados y la bandera de intento de envío, y ajusta la vista para que ningún mensaje aparezca antes de que el usuario haya tenido su oportunidad.
  3. Implementa el ciclo de envío con bloqueo durante la petición y comprueba que pulsar dos veces seguidas no produce dos altas.
  4. Haz que el servidor devuelva un error de validación por campo, encájalo en el mismo canal que la validación local y verifica que se borra en cuanto el usuario edita ese campo.
  5. Escribe un único test de flujo que recorra formulario inválido, corrección, envío, fallo remoto, corrección y éxito, y compara su tiempo y su valor con el de los tests de asignación que borraste en la lección dos.