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

Envío: carga, errores del servidor y accesibilidad del formulario

El instante del envío es donde un formulario deja de ser una pantalla y se convierte en una promesa: hay latencia, hay red que puede fallar, hay un servidor con reglas que el cliente no conoce y hay un usuario que, ante la duda, vuelve a pulsar. Esta lección modela el envío como una máquina de estados explícita que impide el doble disparo, traduce los errores del servidor a mensajes por campo en lugar de volcarlos crudos, distingue el fallo transitorio del permanente y cierra el ciclo con lo que casi siempre se deja fuera: que el resultado, el error y el progreso lleguen también a quien no está mirando la pantalla.

⏱ 19 min

Hasta ahora todo el formulario ocurría dentro del dispositivo, en tiempo cero y sin posibilidad de fracaso. El envío rompe las tres cosas a la vez: introduce una espera que el usuario no controla, una probabilidad de fallo que no depende de lo que escribió y una autoridad remota que puede rechazar datos que localmente eran impecables. Un formulario se juzga por cómo se comporta en ese minuto: si comunica que está trabajando, si impide que el usuario duplique el pedido por ansiedad, si convierte el error en algo accionable y si todo eso llega también a quien escucha la pantalla en lugar de verla.

🎯 Al terminar esta lección sabrás
  • Modelar el envío como una máquina de estados que impide el doble disparo.
  • Comunicar el progreso en el propio control que lo originó.
  • Traducir errores del servidor a mensajes por campo y distinguir lo transitorio de lo permanente.
  • Hacer que progreso, error y éxito sean perceptibles con lector de pantalla.

Una máquina de estados, no un booleano

Un booleano de carga no distingue entre nunca enviado, enviando, fallido y completado, y esa ambigüedad se paga en las ramas de la vista. Un enumerado con valor asociado hace imposibles los estados imposibles y deja el cuerpo de la vista sin condiciones anidadas.

enum EstadoEnvio: Equatable {
    case inactivo
    case enviando
    case fallo(String)
    case completado
}

@State private var estado: EstadoEnvio = .inactivo

private func enviar() async {
    guard estado != .enviando else { return }
    estado = .enviando
    do {
        try await servicio.crearCuenta(formulario)
        estado = .completado
    } catch let error as ErrorServidor {
        aplicar(error)
        estado = .fallo(error.mensajeGeneral)
    } catch {
        estado = .fallo("No hemos podido conectar. Inténtalo de nuevo.")
    }
}
stateDiagram-v2
[*] --> Inactivo
Inactivo --> Enviando: pulsa enviar
Enviando --> Completado: respuesta correcta
Enviando --> Fallo: error de red o de servidor
Fallo --> Enviando: reintento explicito
Fallo --> Inactivo: el usuario edita un campo
Completado --> [*]

La guarda del principio es la primera barrera contra el doble envío, y la segunda es deshabilitar el control. Ninguna de las dos basta frente a una petición que sí llegó pero cuya respuesta se perdió: para eso está la clave de idempotencia, un identificador generado en el cliente que acompaña a la petición y permite al servidor reconocer el reintento como el mismo pedido y no como uno nuevo.

El botón cuenta lo que está pasando

El progreso pertenece al control que lo provocó. Un indicador en el centro de la pantalla obliga al usuario a inferir qué acción está en curso; un botón que se transforma no deja lugar a dudas y conserva el contexto.

Button {
    Task { await enviar() }
} label: {
    if estado == .enviando {
        ProgressView().controlSize(.small)
    } else {
        Text("Crear cuenta")
    }
}
.disabled(estado == .enviando)

Durante

Deshabilita el control mientras dure la operación y mantén visible lo que el usuario escribió. Nunca vacíes el formulario antes de la confirmación.

🔁

Al fallar

Vuelve a un estado editable con los datos intactos y ofrece un reintento explícito. Reintentar solo tú, en silencio, oculta la realidad de la red.

⌨️

Teclado

Cierra el foco antes de enviar: el teclado tapa justo la zona donde aparecerán el progreso y los errores.

Al lograrlo

Cambia de contexto o confirma sin ambigüedad. Un formulario que se limpia sin decir nada parece haberse perdido.

Errores del servidor: traducir, no volcar

El servidor conoce reglas que el cliente no puede conocer: que ese correo ya existe, que el cupón caducó, que la cuenta está bloqueada. Esos errores llegan asociados a campos concretos y merecen aterrizar exactamente donde nació el dato, no en una alerta genérica que obliga a buscar.

private func aplicar(_ error: ErrorServidor) {
    for (campo, mensaje) in error.porCampo {
        erroresRemotos[campo] = mensaje
        madurados.insert(campo)
    }
    campoActivo = error.porCampo.keys.first
}

La distinción operativa es entre lo transitorio —tiempo de espera agotado, sin conexión, servicio saturado— que se resuelve reintentando el mismo dato, y lo permanente —dato duplicado, regla de negocio incumplida— que exige que el usuario cambie algo. Ofrecer reintentar ante un error permanente invita a repetir el fracaso; pedir corrección ante un error de red culpa al usuario de algo que no hizo.

Traducir también significa no volcar. El texto crudo de un error de red, con su código y su dominio, no le dice nada al usuario y sí le dice bastante a quien inspeccione la aplicación. Guarda el detalle técnico en el registro y presenta una frase que indique qué puede hacer ahora.

Que el formulario se pueda oír

Todo lo anterior existe en dos capas: la visible y la que recorre un lector de pantalla. Un estado de carga que solo es una rueda girando, o un error que solo es un texto rojo aparecido lejos del foco, no han sido comunicados a una parte de tus usuarios.

Button { Task { await enviar() } } label: {
    if estado == .enviando {
        ProgressView().controlSize(.small)
    } else {
        Text("Crear cuenta")
    }
}
.disabled(estado == .enviando)
.accessibilityLabel(estado == .enviando ? "Enviando" : "Crear cuenta")
.accessibilityFocused($focoAccesible, equals: .boton)

TextField("Correo", text: $formulario.correo)
    .accessibilityLabel("Correo electrónico")
    .accessibilityValue(erroresRemotos[.correo] ?? formulario.correo)
    .accessibilityFocused($focoAccesible, equals: .correo)

Tres reglas resumen la práctica. El texto de sugerencia de un campo no es su etiqueta: desaparece al escribir, así que declara accessibilityLabel explícito. El desenlace del envío debe anunciarse con AccessibilityNotification.Announcement, porque un cambio de estado que no mueve el foco puede pasar completamente inadvertido. Y un control deshabilitado se anuncia como atenuado sin decir por qué, de modo que si mantienes esa decisión debes acompañarla de una explicación accesible junto al botón.

📝
La prueba que descubre casi todo

Recorre el formulario entero con VoiceOver, la tipografía más grande y el modo oscuro, sin mirar la pantalla en la primera pasada. Los fallos que aparecen ahí —etiquetas que son la sugerencia, errores que nadie anuncia, filas que se cortan, información transmitida solo por color— son los mismos que degradan la experiencia de cualquier usuario con prisa o con poca luz.

El envío convierte un formulario en un sistema distribuido de dos nodos

Mientras todo ocurre en el dispositivo, el formulario es un programa determinista: mismo estado, mismo resultado, sin espera. En el momento en que pulsas enviar aparece un segundo nodo, un canal poco fiable entre ambos y, con ellos, el catálogo entero de problemas de los sistemas distribuidos, en miniatura y a la vista de un usuario que solo quería darse de alta. El primero es la incertidumbre del resultado: si la respuesta no llega, el cliente no puede saber si la operación ocurrió, y esa ignorancia no se arregla con más código en la vista sino con idempotencia acordada entre las dos partes. El segundo es la autoridad dividida: el cliente valida forma, el servidor valida verdad, y ninguna cantidad de comprobación local elimina la posibilidad de que el servidor diga que no; por eso el formulario debe conservar intacto lo que el usuario escribió hasta tener confirmación, porque su trabajo es el único dato que no se puede reconstruir. El tercero es el tiempo: un intervalo sin señales convierte a cualquier usuario razonable en un usuario que vuelve a pulsar, de modo que el estado de carga no es cortesía visual sino el mecanismo que evita duplicados en tu base de datos. Y sobre todo ello se apoya la exigencia final, que es la que separa un formulario aceptable de uno profesional: cada uno de esos estados —trabajando, ha fallado esto en este campo, se ha completado— es información, y la información que solo existe como un cambio de color o un giro de rueda no ha sido comunicada a todos los usuarios. Hacerla audible y enfocable no es una capa que se añade al final; es terminar de escribir la máquina de estados.

⚔️ Lleva el envío hasta el final
  1. Sustituye tu booleano de carga por un enumerado con los cuatro estados y elimina las condiciones anidadas que sobren en la vista.
  2. Añade la guarda contra el doble disparo y comprueba con pulsaciones rápidas que solo sale una petición.
  3. Genera una clave de idempotencia en el cliente y acuerda con tu servicio que dos peticiones con la misma clave son la misma operación.
  4. Simula un error del servidor asociado a un campo concreto, hazlo aterrizar bajo ese campo y lleva allí el foco.
  5. Recorre el formulario completo con VoiceOver activado y con la tipografía más grande, y corrige todo estado que no se anuncie o no quepa.