wandres.dev
FORMULARIOS Y ENTRADA · texto, foco y validación

Validación en vivo: estado derivado sin castigar al que escribe

Un formulario validado a destiempo enseña al usuario a ignorar los errores. La solución no es validar menos sino separar dos preguntas que suelen confundirse: si el valor actual es correcto, que es una función pura del estado y por tanto no debe almacenarse nunca, y si ha llegado el momento de decírselo al usuario, que depende del foco, del historial de edición y del intento de envío. Esta lección construye la validez como estado derivado, modela la maduración de cada campo, retrasa los mensajes con criterio y explica por qué deshabilitar el botón de envío es una decisión con coste.

⏱ 18 min

Escribe la primera letra de un correo y el formulario ya te grita en rojo que no es válido. Claro que no lo es: acabas de empezar. Ese error, técnicamente correcto y humanamente absurdo, es el pecado original de la validación en vivo, y su castigo es doble: irrita mientras escribes y, peor, entrena para ignorar los mensajes rojos, de modo que cuando por fin aparece uno que importa ya no lo lee nadie. Validar bien no consiste en comprobar más cosas, sino en decidir con precisión cuándo el usuario merece saberlas.

🎯 Al terminar esta lección sabrás
  • Calcular la validez como estado derivado en lugar de almacenarla y sincronizarla.
  • Modelar la maduración de un campo con marcas de edición, foco e intento de envío.
  • Diferir mensajes con retardo o pérdida de foco sin ocultar información necesaria.
  • Sopesar el coste real de deshabilitar el botón de envío.

La validez es una función, no una variable

La tentación es declarar una propiedad de estado con el resultado de validar y mantenerla al día desde cada punto de edición. Eso crea una segunda fuente de la verdad que empezará a mentir en cuanto alguien modifique un campo por una vía que olvidaste. La validez se deriva del estado en cada recomputación, que es exactamente lo que SwiftUI hace mejor.

enum ErrorCampo: String {
    case correoVacio = "Escribe tu correo."
    case correoMalFormado = "Falta la arroba o el dominio."
    case claveCorta = "La contraseña necesita 8 caracteres o más."
}

struct Formulario {
    var correo = ""
    var clave = ""

    var errorCorreo: ErrorCampo? {
        if correo.isEmpty { return .correoVacio }
        return correo.contains("@") ? nil : .correoMalFormado
    }
    var errorClave: ErrorCampo? {
        clave.count >= 8 ? nil : .claveCorta
    }
    var esValido: Bool { errorCorreo == nil && errorClave == nil }
}

Un ErrorCampo opcional por campo, en lugar de un booleano global, permite dos cosas que un booleano no permite: decir por qué falla ese campo concreto y componer la validez del conjunto sin duplicar reglas. El coste de recalcular estas comprobaciones en cada recomposición es despreciable; si alguna vez no lo fuera, ahí es donde entra una caché explícita, nunca antes.

💡
Valida el dominio, no la cadena

Comprobar que un correo contiene una arroba es una heurística de forma, no una verificación de existencia. La regla operativa es validar en el cliente solo lo que sirve para evitarle al usuario un viaje inútil al servidor, y aceptar que la autoridad última sobre la validez de un dato la tiene el sistema que lo consume.

Cuándo decírselo

Que un campo sea inválido y que su error deba mostrarse son proposiciones distintas. La segunda depende del historial: si el usuario ya escribió ahí, si ya salió del campo, si ya intentó enviar. Añade a cada campo un estado de maduración y mostrarás el error en el momento en que deja de ser una interrupción para convertirse en información.

stateDiagram-v2
[*] --> Intacto
Intacto --> Editando: primera pulsacion
Editando --> Editando: sigue escribiendo
Editando --> Maduro: pierde el foco
Intacto --> Maduro: intento de envio
Maduro --> Editando: vuelve a editar
Maduro --> [*]: valido y enviado
@State private var formulario = Formulario()
@State private var madurados: Set<Campo> = []
@State private var intentoDeEnvio = false
@FocusState private var campoActivo: Campo?

private func debeMostrar(_ campo: Campo) -> Bool {
    intentoDeEnvio || madurados.contains(campo)
}

var body: some View {
    Form { /* campos */ }
        .onChange(of: campoActivo) { anterior, _ in
            if let anterior { madurados.insert(anterior) }
        }
}

La pérdida de foco es el disparador natural porque coincide con el momento en que el usuario declara terminado el campo. El intento de envío madura todos a la vez, incluidos los que nunca se tocaron, que es justo lo que necesita quien pulsó enviar sin rellenar nada.

⚠️
Un retardo no sustituye a la maduración

Esperar medio segundo con task(id:) y Task.sleep evita el parpadeo mientras se teclea rápido, y es útil para validaciones caras como consultar si un nombre de usuario está libre. Pero un retardo sigue mostrando el error a quien apenas ha escrito tres letras: solo lo hace medio segundo más tarde. Retardo y maduración resuelven problemas distintos y se usan juntos.

Mensajes por campo y el botón deshabilitado

El mensaje pertenece al campo, no al formulario. Colócalo junto al control que lo origina, escríbelo en términos de qué hacer y no de qué está mal, y no dependas del color para transmitirlo: hay usuarios que no lo distinguen y otros que escuchan la pantalla en lugar de verla.

VStack(alignment: .leading, spacing: 4) {
    TextField("Correo", text: $formulario.correo)
        .focused($campoActivo, equals: .correo)
    if debeMostrar(.correo), let error = formulario.errorCorreo {
        Text(error.rawValue)
            .font(.footnote)
            .foregroundStyle(.red)
            .accessibilityLabel("Error en correo. \(error.rawValue)")
    }
}

Sobre el botón: deshabilitarlo mientras el formulario no sea válido parece una cortesía y con frecuencia es un callejón sin salida. Un control atenuado no explica por qué lo está, y el usuario que rellenó todo menos un campo escondido bajo el teclado se queda mirando un botón muerto. La alternativa —botón siempre activo que, al pulsarse, madura todos los campos, muestra los errores y lleva el foco al primero defectuoso— informa en lugar de bloquear.

Comprobaciones que necesitan tiempo

Algunas reglas no se pueden decidir localmente: si un nombre de usuario está libre, si un cupón sigue vigente, si un código postal existe. Esas comprobaciones tienen latencia y pueden fallar, así que no caben en una propiedad calculada: necesitan su propio estado con un caso para el intervalo en que aún no se sabe la respuesta.

enum Disponibilidad { case sinComprobar, comprobando, libre, ocupado, error }

@State private var disponibilidad: Disponibilidad = .sinComprobar

.task(id: formulario.usuario) {
    guard formulario.usuario.count >= 3 else {
        disponibilidad = .sinComprobar; return
    }
    disponibilidad = .comprobando
    do {
        try await Task.sleep(for: .milliseconds(400))
        disponibilidad = try await servicio.estaLibre(formulario.usuario)
            ? .libre : .ocupado
    } catch is CancellationError {
        // Otra pulsación llegó antes: no toques el estado.
    } catch {
        disponibilidad = .error
    }
}

El modificador task(id:) cancela la tarea anterior cuando cambia el identificador, de modo que la espera inicial actúa como retardo y el resto de pulsaciones descartan la consulta en vuelo sin escribir un temporizador a mano. La rama de cancelación debe quedarse callada: escribir en ella un estado de error convertiría el hecho normal de seguir escribiendo en un fallo visible.

📝
Lo desconocido no es lo inválido

Mientras la comprobación está en curso, el campo no es válido ni inválido: es indeterminado. Habilitar el envío en ese estado provoca un rechazo del servidor evitable, y marcarlo en rojo acusa sin pruebas. Muestra progreso junto al campo y trata comprobando como bloqueante para el envío pero silencioso para el usuario.

Validar es comunicar, y el silencio también comunica

Hay dos afirmaciones muy distintas que una interfaz puede hacer sobre un campo, y confundirlas es el error central de casi todas las validaciones que molestan. La primera es esto está mal: un juicio sobre el contenido. La segunda es esto todavía no está terminado: un juicio sobre el proceso. Un campo de correo que contiene la letra a es incompleto, no incorrecto, y tratarlo como incorrecto acusa al usuario de un fallo que aún no ha tenido ocasión de cometer. De esa distinción se sigue toda la arquitectura de la lección: la validez es una función pura del estado y puede calcularse siempre, en cada pulsación, sin coste conceptual alguno; la visibilidad del error es una función del proceso, y por tanto necesita memoria de lo que el usuario ya hizo. Separarlas en el código —una propiedad calculada por un lado, un conjunto de campos madurados por otro— no es un refinamiento estético, es lo que hace posible cambiar la política de cuándo hablar sin tocar una sola regla de negocio. Y tiene una implicación sobre la economía de la atención que conviene tomarse en serio: el mensaje de error es un recurso que se agota. Cada aviso mostrado antes de tiempo reduce la probabilidad de que se lea el siguiente, hasta que el rojo se convierte en ruido de fondo y el formulario pierde su único canal de corrección. El silencio bien colocado no es ausencia de validación, es la parte de la validación que respeta que el usuario está en mitad de una frase.

⚔️ Enseña el error en el momento justo
  1. Convierte cualquier propiedad de estado que hoy almacene la validez en una propiedad calculada y borra el código que la sincronizaba.
  2. Sustituye el booleano global de validez por un error opcional por campo con mensaje propio.
  3. Implementa la maduración por pérdida de foco con onChange sobre la propiedad de foco, y comprueba que escribir la primera letra ya no muestra nada.
  4. Haz que el intento de envío madure todos los campos a la vez y lleve el foco al primero defectuoso.
  5. Prueba las dos variantes del botón —deshabilitado frente a siempre activo con errores al pulsar— con alguien que no conozca el formulario, y quédate con la que necesite menos explicaciones.