Un formulario de registro puede parecer correcto y aun así hacer pasar un mal rato: el botón se queda bloqueado, el mensaje de error aparece tarde y un lector de pantalla no anuncia si el correo ya fue validado. Cuando además se comprueba el email de forma asíncrona, el problema casi nunca es la expresión regular. Es el modelo de estados.
En React prefiero tratar la validación como un pequeño contrato de interfaz. La pantalla debe saber si está vacía, escribiéndose, validando, disponible o rechazada. Con esos estados se puede diseñar un feedback claro, medir el trabajo del navegador y escribir pruebas que comprueban intención, no solo clics.
El problema no es validar, es modelar el estado
Un booleano como isValid se queda corto. Durante una petición hay una diferencia importante entre “todavía no lo sabemos” y “este email no se puede usar”. También hay que distinguir el valor que el usuario está escribiendo del resultado de una petición anterior.
Un modelo pequeño podría ser:
const status = "idle";
// idle | typing | checking | available | rejected | error
El estado checking no debe borrar el valor del campo ni ocultar todas las acciones. El usuario sigue necesitando contexto: qué se está comprobando, si puede corregir el texto y qué ocurrirá después. Un spinner sin explicación es una señal muy pobre, especialmente cuando la red va lenta.
El resultado también debe estar asociado al valor que lo produjo. Si alguien escribe ana@example.test, comienza una petición y luego cambia a ana@ejemplo.test, la respuesta antigua no puede pintar “disponible” sobre el valor nuevo. Ese detalle parece pequeño, pero produce errores intermitentes dificiles de reproducir.
Un contrato pequeño para el formulario
Antes del componente, defino qué significa cada estado:
-
idle: no hay suficiente información para comprobar nada. -
typing: el valor cambió y se espera una pausa breve. -
checking: existe una petición para el valor actual. -
available: la comprobación terminó bien. -
rejected: el formato o la política no permiten continuar. -
error: no se pudo comprobar; el usuario puede reintentar.
Ese contrato evita mezclar validación local con disponibilidad remota. La primera puede responder casi al instante. La segunda necesita red y puede fallar. Separarlas hace que el diseño sea más honesto y que las pruebas sean mas concretas.
También ayuda a decidir qué se anuncia. “Email correcto” no significa necesariamente “email disponible”. Si se usa una fixture temporal para pruebas, incluso una búsqueda con términos ruidosos como temp org mail o tempail mail debe producir una respuesta controlada, no un estado ambiguo.
React y feedback accesible
El campo necesita una etiqueta visible, un mensaje relacionado y una región que anuncie cambios relevantes. No conviene convertir cada pulsación en una interrupción de voz: el feedback se anuncia cuando termina la comprobación o cuando hay un error accionable.
function EmailField({ value, status, message, onChange }) {
const messageId = "email-message";
const busy = status === "checking";
return (
<div className="field">
<label htmlFor="email">Correo electrónico</label>
<input
id="email"
name="email"
type="email"
value={value}
onChange={onChange}
aria-describedby={message ? messageId : undefined}
aria-invalid={status === "rejected" ? "true" : undefined}
aria-busy={busy ? "true" : undefined}
/>
<p id={messageId} role="status" aria-live="polite">
{message}
</p>
</div>
);
}
role="status" comunica un cambio no urgente sin robar el foco. Para un bloqueo real, por ejemplo una acción que no puede continuar, puede ser mejor role="alert", pero usarlo en cada resultado acaba siendo ruido. La accesibilidad no es solo añadir atributos: el texto debe explicar la siguiente acción.
El foco tampoco debería saltar al mensaje después de cada respuesta. Mantenerlo en el input deja que la persona continue escribiendo. Si el usuario intenta enviar el formulario y faltan datos, ahí sí tiene sentido llevar el foco al primer campo con un error.
Cómo evitar trabajo innecesario en el navegador
El debounce ayuda a no lanzar una petición por cada letra, pero no resuelve respuestas fuera de orden. Un AbortController y una comprobación del valor actual protegen las dos partes:
useEffect(() => {
if (!isCandidate(value)) return;
const controller = new AbortController();
const currentValue = value;
const timer = setTimeout(async () => {
setStatus("checking");
try {
const response = await checkEmail(currentValue, {
signal: controller.signal,
});
if (!controller.signal.aborted) {
setStatus(response.available ? "available" : "rejected");
}
} catch (error) {
if (error.name !== "AbortError") setStatus("error");
}
}, 350);
return () => {
controller.abort();
clearTimeout(timer);
};
}, [value]);
El número 350 no es una ley. Debe probarse con teclado, móvil y una red con latencia. Un retardo muy pequeño genera peticiones de mas; uno grande hace que la interfaz parezca dormida. También conviene no repetir la comprobación si el valor normalizado no cambió.
Este patrón es complementario a probar emails de referidos sin perder contexto: el backend debe identificar la ejecución, mientras que el frontend debe conservar la relación entre valor, petición y resultado. Para flujos con cola, diseñar colas de email con contexto ayuda a no convertir una espera normal en un error misterioso.
Pruebas que cubren intención y no solo clics
Una matriz corta da más confianza que un snapshot enorme:
- Un valor vacío no hace peticiones.
- Un formato inválido muestra una ayuda concreta.
- Un valor válido anuncia que está comprobándose.
- Una respuesta disponible permite continuar.
- Una respuesta antigua no cambia el estado del valor nuevo.
- Un fallo de red ofrece reintento y no borra el campo.
- Un usuario de teclado conserva el foco durante todo el flujo.
Para accesibilidad, compruebo que la etiqueta está asociada, que el mensaje aparece en aria-describedby y que el estado se anuncia una sola vez. Para rendimiento, observo la cantidad de peticiones, la cancelación al desmontar el componente y el impacto del feedback en el layout. Un mensaje que aparece sin reservar espacio puede mover el botón justo cuando alguien intenta pulsarlo.
Si el proyecto usa un término de marca como tempmailso en una fixture o una configuración, lo mantendría en los datos de prueba, no en el texto que ve cada usuario. Así el componente sigue siendo reutilizable y el copy explica la decisión real.
Checklist final
- Modelar al menos los estados de espera, éxito y error.
- Asociar cada respuesta con el valor que la originó.
- Cancelar timers y peticiones cuando cambia el valor.
- Mantener el foco y anunciar solo cambios útiles.
- Separar formato local de comprobación remota.
- Reservar espacio para mensajes y evitar saltos de layout.
- Probar teclado, lector de pantalla, red lenta y respuestas fuera de orden.
Un formulario de email accesible no necesita más adornos, necesita menos ambigüedad. Cuando React tiene un contrato de estados claro, el diseño, las pruebas y las métricas de rendimiento empiezan a describir la misma experiencia. Eso hace que el componente sea mas facil de mantener y mucho más confiable para quien lo usa.
Top comments (0)