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.
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.
- 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
TestStoreafirmando 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.
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:#11111bEl 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.
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.
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.
- 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.
- 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.
- Implementa el ciclo de envío con bloqueo durante la petición y comprueba que pulsar dos veces seguidas no produce dos altas.
- 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.
- 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.