DEV Community

Silviu Technology
Silviu Technology

Posted on

React: calma visual para checks async

Cuando un formulario revisa un email contra una regla remota, muchas interfaces se ponen raras demasiado pronto. Aparece un spinner, el boton cambia de tamano, el helper text desaparece y vuelve, y la persona siente que el campo esta peleando con ella. En frontend, ese detalle pesa bastante mas de lo que parece.

En equipos donde trabajamos signup, soporte y analitica a la vez, el problema casi nunca es la llamada async por si sola. El problema es el ritmo visual que le ponemos encima. Si un usuario escribe rapido, borra una letra o pega un valor temporal tipo temp gamil com, la UI puede entrar en una secuencia medio caotica: pendiente, warning, ok, pendiente otra vez. Todo es tecnicamente correcto, pero la experiencia sale algo torpe.

Segun Google, los cambios inesperados de layout siguen afectando la sensacion de calidad y la lectura del flujo (web.dev). Y Nielsen Norman Group lleva tiempo remarcando que los mensajes de estado deben apoyar la tarea, no competir con ella (NN/g). Para mi, ese es el punto de partida.

El problema no es la llamada remota, es el ritmo visual

Una comprobacion async de email suele existir por motivos validos:

  • detectar dominios desechables o de baja confianza
  • preparar una advertencia antes del submit
  • evitar reglas duplicadas entre frontend y backend
  • dar una pista temprana sin bloquear el flujo

Nada de eso esta mal. Lo que si veo mal seguido es mezclar todos esos objetivos dentro del mismo segundo visual. El usuario escribe, el campo lanza una request, el texto de ayuda cambia tres veces, y el CTA sube o baja unos pixeles. Ese mini-desorden rompe confianza aunque el formulario termine funcionando bien.

Me sirve pensar el componente igual que una cola visible para correos async: no todo cambio interno merece una reaccion dramatica en pantalla. A veces la mejor UI es la que reconoce que algo esta pasando, pero no interrumpe.

Un estado pendiente que no rompa el formulario

Mi patron favorito es mantener una sola linea estable debajo del input y tratar el estado remoto como una progresion corta:

  1. idle: ayuda neutral antes de cualquier comprobacion
  2. pending: mensaje suave mientras corre la validacion
  3. warning o success: resultado final si realmente aporta algo
  4. error: solo para un problema concreto y accionable

La clave es que la region exista siempre. No aparece a empujones, no mueve el boton y no roba foco. Tampoco me gusta mostrar success verde brillante en cada caso valido, porque termina haciendo tanto ruido como el error. Muchas veces basta con volver a una ayuda normal.

Tambien intento retrasar un poco la comprobacion remota. Si validas en cada tecla, un valor incompleto como tem email activa trabajo que todavia no tiene sentido. Un pequeno debounce o una comprobacion al salir del campo suele ser suficiente. En productos con mucho QA, este cambio hace que las pruebas limpias para emails de upgrade sean bastante mas faciles de leer despues.

Un ejemplo de React con transiciones simples

Este enfoque mantiene el helper text fijo y evita que el estado pendiente sacuda el layout:

import { useEffect, useId, useState, useTransition } from "react";

type HintState =
  | { kind: "idle"; message: string }
  | { kind: "pending"; message: string }
  | { kind: "warning"; message: string }
  | { kind: "error"; message: string };

const DEFAULT_HINT: HintState = {
  kind: "idle",
  message: "Usa un correo que puedas revisar hoy para terminar el registro."
};

function looksLikeEmail(value: string) {
  return /\S+@\S+\.\S+/.test(value);
}

async function checkEmailRisk(value: string) {
  const response = await fetch(`/api/email-risk?email=${encodeURIComponent(value)}`);

  if (!response.ok) {
    throw new Error("No se pudo revisar el correo");
  }

  return response.json() as Promise<{ disposable: boolean }>;
}

export function EmailField() {
  const hintId = useId();
  const [email, setEmail] = useState("");
  const [hint, setHint] = useState<HintState>(DEFAULT_HINT);
  const [isPending, startTransition] = useTransition();

  useEffect(() => {
    if (!looksLikeEmail(email)) {
      setHint(DEFAULT_HINT);
      return;
    }

    const timeoutId = window.setTimeout(() => {
      setHint({ kind: "pending", message: "Estamos comprobando este dominio..." });

      startTransition(async () => {
        try {
          const result = await checkEmailRisk(email);

          setHint(
            result.disposable
              ? {
                  kind: "warning",
                  message: "Este correo podria expirar antes de recibir mensajes futuros."
                }
              : DEFAULT_HINT
          );
        } catch {
          setHint({
            kind: "error",
            message: "No pudimos validar el dominio ahora mismo. Puedes continuar igual."
          });
        }
      });
    }, 300);

    return () => window.clearTimeout(timeoutId);
  }, [email]);

  return (
    <label className="field">
      <span>Correo</span>
      <input
        type="email"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
        aria-describedby={hintId}
        aria-invalid={hint.kind === "error" || undefined}
      />
      <span id={hintId} className="hint" data-kind={hint.kind}>
        {isPending && hint.kind === "pending" ? hint.message : hint.message}
      </span>
    </label>
  );
}
Enter fullscreen mode Exit fullscreen mode

No es una solucion exotica, pero tiene tres ventajas practicas:

  • reduce requests inutiles mientras la persona aun esta escribiendo
  • mantiene el layout quieto casi todo el tiempo
  • vuelve mucho mas legible la diferencia entre ayuda, espera y advertencia

Si la politica real vive en backend, mejor todavia. El frontend acompana la decision sin fingir que controla todo. Eso deja el flujo mas honesto y bastante menos fragil.

CSS pequeno para evitar saltos y ruido

Una parte importante de este patron ni siquiera esta en JavaScript. Esta en reservar espacio y bajar intensidad visual:

.hint {
  display: block;
  min-height: 1.4rem;
  margin-top: 0.4rem;
  font-size: 0.92rem;
  line-height: 1.4;
  color: #5b6470;
}

.hint[data-kind="pending"] {
  color: #3b5ccc;
}

.hint[data-kind="warning"] {
  color: #9a5a00;
}

.hint[data-kind="error"] {
  color: #b42318;
}
Enter fullscreen mode Exit fullscreen mode

Ese min-height parece una tonteria, pero evita un montooon de micro saltos. Y cuando el mensaje pendiente entra en una linea reservada, la validacion se siente mucho mas rapida aunque la latencia real no cambie. Ese tipo de polish suele dar mejor resultado que meter otro spinner pequeñito por todos lados.

Que medir despues del release

Yo revisaria al menos estas senales:

  • abandono del campo de email antes y despues del cambio
  • tiempo hasta completar el formulario
  • frecuencia de reintentos por el mismo valor
  • cantidad de respuestas pendientes canceladas por nueva escritura
  • sesiones con layout shift en el bloque del formulario

Si la comprobacion remota tarda bastante, incluso puedes registrar cuanto tiempo pasa el estado pending. No para presumir precision, sino para saber cuando una interfaz necesita otra estrategia. A veces el mejor cambio no es optimizar la API; es dejar de molestar visualmente mientras esperas.

Q&A

Debo bloquear el submit mientras corre el check async?

Solo si la regla es critica para el negocio o la seguridad. En muchos casos basta con dejar continuar y resolver la politica final en servidor.

Conviene mostrar un spinner dentro del input?

Puede funcionar, pero solo si no empuja iconos, padding o etiquetas. Si el spinner mueve el campo aunque sea un poco, normalmente sale mas caro de lo que ayuda.

Y si el dominio parece temporal pero el usuario quiere seguir?

Prefiero una advertencia clara y no un castigo inmediato. Si el producto necesita correo duradero para recovery o billing, esa decision debe explicarse bien y confirmarse en backend.

Top comments (0)