Visualización de Errores de Formulario
Errores de campos, errores de formulario, notificaciones tipo toast y mensajes inline — patrones para mostrar retroalimentación de validación a los usuarios.
Receta
Tarjeta de receta de referencia rápida — lista para copiar y pegar.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const Schema = z.object({
email: z.string().email("Email inválido"),
password: z.string().min(8, "Al menos 8 caracteres"),
});
function FormWithErrors() {
const { register, handleSubmit, formState: { errors }, setError } = useForm({
resolver: zodResolver(Schema),
});
return (
<form onSubmit={handleSubmit(async (data) => {
const res = await fetch("/api/login", { method: "POST", body: JSON.stringify(data) });
if (!res.ok) setError("root", { message: "Credenciales inválidas" });
})}>
{/* Error a nivel de formulario */}
{errors.root && (
<div role="alert" className="rounded bg-red-50 p-3 text-sm text-red-700">
{errors.root.message}
</div>
)}
{/* Error a nivel de campo */}
<div>
<input {...register("email")} aria-invalid={!!errors.email} aria-describedby="email-error" />
{errors.email && <p id="email-error" role="alert" className="text-sm text-red-600">{errors.email.message}</p>}
</div>
<div>
<input {...register("password")} type="password" aria-invalid={!!errors.password} />
{errors.password && <p role="alert" className="text-sm text-red-600">{errors.password.message}</p>}
</div>
<button type="submit">Enviar</button>
</form>
);
}Cuándo usarlo: Todo formulario necesita visualización de errores. Elige el patrón basándote en el tipo de error — nivel de campo para validación, nivel de formulario para errores del servidor, toast para resultados asincronos.
Ejemplo Funcional
"use client";
import { useState, useEffect, useRef } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const Schema = z.object({
name: z.string().min(1, "El nombre es requerido"),
email: z.string().email("Por favor ingresa una dirección de email válida"),
phone: z.string().regex(/^\+?[\d\s-()]+$/, "Número de teléfono inválido").optional().or(z.literal("")),
message: z.string().min(20, "El mensaje debe tener al menos 20 caracteres"),
});
type FormData = z.infer<typeof Schema>;
// Componente de error inline con animación
function FieldError({ message }: { message?: string }) {
if (!message) return null;
return (
<p role="alert" className="mt-1 animate-[slideDown_0.2s_ease-out] text-sm text-red-600">
{message}
</p>
);
}
// Banner de error a nivel de formulario
function FormBanner({ message, type }: { message: string; type: "error" | "success" }) {
const colors = {
error: "bg-red-50 border-red-200 text-red-800",
success: "bg-green-50 border-green-200 text-green-800",
};
return (
<div role="alert" className={`rounded border p-3 text-sm ${colors[type]}`}>
{message}
</div>
);
}
// Notificación tipo toast
function Toast({ message, onClose }: { message: string; onClose: () => void }) {
useEffect(() => {
const timer = setTimeout(onClose, 5000);
return () => clearTimeout(timer);
}, [onClose]);
return (
<div
role="status"
aria-live="polite"
className="fixed bottom-4 right-4 rounded-lg bg-gray-900 px-4 py-3 text-sm text-white shadow-lg"
>
{message}
<button onClick={onClose} className="ml-3 text-gray-400 hover:text-white">
Cerrar
</button>
</div>
);
}
export function ContactFormWithErrors() {
const [toast, setToast] = useState<string | null>(null);
const errorSummaryRef = useRef<HTMLDivElement>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting, submitCount },
setError,
reset,
} = useForm<FormData>({
resolver: zodResolver(Schema),
mode: "onBlur",
});
// Enfoca el resumen de errores cuando los errores aparecen
useEffect(() => {
if (Object.keys(errors).length > 0 && submitCount > 0) {
errorSummaryRef.current?.focus();
}
}, [errors, submitCount]);
async function onSubmit(data: FormData) {
try {
const res = await fetch("/api/contact", {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
if (!res.ok) throw new Error("Error del servidor");
reset();
setToast("¡Mensaje enviado exitosamente!");
} catch {
setError("root", { message: "Falló al enviar. Por favor intenta de nuevo." });
}
}
const errorCount = Object.keys(errors).filter((k) => k !== "root").length;
return (
<>
<form onSubmit={handleSubmit(onSubmit)} className="max-w-md space-y-4" noValidate>
{/* Resumen de errores al inicio */}
{errorCount > 0 && submitCount > 0 && (
<div
ref={errorSummaryRef}
tabIndex={-1}
role="alert"
className="rounded border border-red-200 bg-red-50 p-3 outline-none"
>
<p className="font-medium text-red-800">
Por favor corrige {errorCount} {errorCount === 1 ? "error" : "errores"}:
</p>
<ul className="mt-1 list-inside list-disc text-sm text-red-700">
{errors.name && <li>{errors.name.message}</li>}
{errors.email && <li>{errors.email.message}</li>}
{errors.phone && <li>{errors.phone.message}</li>}
{errors.message && <li>{errors.message.message}</li>}
</ul>
</div>
)}
{/* Error del servidor */}
{errors.root && <FormBanner message={errors.root.message!} type="error" />}
<div>
<label htmlFor="name" className="block text-sm font-medium">
Nombre <span className="text-red-500">*</span>
</label>
<input
id="name"
{...register("name")}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? "name-err" : undefined}
className={`w-full rounded border p-2 ${errors.name ? "border-red-500" : ""}`}
/>
{errors.name && <FieldError message={errors.name.message} />}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email <span className="text-red-500">*</span>
</label>
<input
id="email"
{...register("email")}
aria-invalid={!!errors.email}
className={`w-full rounded border p-2 ${errors.email ? "border-red-500" : ""}`}
/>
<FieldError message={errors.email?.message} />
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium">Teléfono</label>
<input
id="phone"
{...register("phone")}
aria-invalid={!!errors.phone}
className={`w-full rounded border p-2 ${errors.phone ? "border-red-500" : ""}`}
/>
<FieldError message={errors.phone?.message} />
</div>
<div>
<label htmlFor="msg" className="block text-sm font-medium">
Mensaje <span className="text-red-500">*</span>
</label>
<textarea
id="msg"
{...register("message")}
rows={4}
aria-invalid={!!errors.message}
className={`w-full rounded border p-2 ${errors.message ? "border-red-500" : ""}`}
/>
<FieldError message={errors.message?.message} />
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? "Enviando..." : "Enviar Mensaje"}
</button>
</form>
{toast && <Toast message={toast} onClose={() => setToast(null)} />}
</>
);
}Lo que esto demuestra:
- Banner de resumen de errores con conteo y lista
- Errores inline por campo con animación
- Errores a nivel de formulario via
setError("root") - Toast para retroalimentación de éxito
- Gestión de enfoque para el resumen de errores
aria-invalidyrole="alert"para accesibilidad
Análisis Profundo
Cómo Funciona
- El objeto
errorsde RHF refleja la forma del esquema del formulario — accede aerrors.fieldName.message setError("root", ...)establece un error no vinculado a un campo accesible viaerrors.rootrole="alert"hace que los lectores de pantalla anuncien el error inmediatamentearia-invalid={true}marca el input como inválido para la tecnología de asistenciaaria-describedbyvincula el input a su elemento de mensaje de error- Resumen de errores con
tabIndex={-1}yfocus()asegura que usuarios de teclado noten los errores
Variaciones
Toast con Sonner:
import { toast } from "sonner";
async function onSubmit(data: FormData) {
try {
await submitForm(data);
toast.success("¡Guardado exitosamente!");
} catch (err) {
toast.error("Algo salió mal", { description: err.message });
}
}Error boundary para errores inesperados:
function FormErrorBoundary({ children }: { children: React.ReactNode }) {
return (
<ErrorBoundary
fallback={
<div role="alert" className="rounded bg-red-50 p-4 text-red-800">
Algo salió mal. Por favor recarga e intenta de nuevo.
</div>
}
>
{children}
</ErrorBoundary>
);
}Pistas inline que se convierten en errores:
function PasswordField({ register, error }: { register: any; error?: FieldError }) {
const value = useWatch({ name: "password" });
const rules = [
{ test: (v: string) => v.length >= 8, label: "8+ caracteres" },
{ test: (v: string) => /[A-Z]/.test(v), label: "Letra mayúscula" },
{ test: (v: string) => /\d/.test(v), label: "Número" },
];
return (
<div>
<input {...register("password")} type="password" />
<ul className="mt-1 space-y-0.5 text-xs">
{rules.map((r) => (
<li key={r.label} className={r.test(value || "") ? "text-green-600" : "text-gray-400"}>
{r.test(value || "") ? "check" : "circle"} {r.label}
</li>
))}
</ul>
</div>
);
}Notas de TypeScript
// Tipo FieldError de react-hook-form
import type { FieldError } from "react-hook-form";
function ErrorMessage({ error }: { error?: FieldError }) {
if (!error) return null;
return <p className="text-red-600">{error.message}</p>;
}
// Claves de error tipadas
type FormErrors = Record<keyof FormData, FieldError | undefined>;Trampas
-
Error flash en el primer render — Si
mode: "all", los errores se muestran antes de que el usuario interactúe. Arreglo: Usamode: "onBlur"omode: "onSubmit"y muestra los errores solo después de la primera interacción. -
Toast vs inline para validación — Los toasts desaparecen, lo que dificulta encontrar qué campo falló. Arreglo: Usa errores inline para validación; reserva los toasts para mensajes de éxito y errores del servidor.
-
Anuncios del lector de pantalla — Múltiples elementos con
role="alert"anunciando simultáneamente abruma a los usuarios. Arreglo: Usa un único resumen de errores conrole="alert"y errores individuales sin él, o usaaria-live="polite". -
Borde de error sin mensaje — Un borde rojo solo no ayuda a usuarios con daltonismo. Arreglo: Siempre empareja indicadores visuales con mensajes de texto.
Alternativas
| Alternativa | Úsalo Cuando | No lo Uses Cuando |
|---|---|---|
| Validación HTML nativa | Solo necesitas validaciones de requerido y tipo | Necesitas mensajes de error personalizados o reglas complejas |
| react-hot-toast | Toast ligero con dependencias mínimas | Necesitas características ricas de toast (promise, renderizado personalizado) |
| Sonner | Quieres toasts hermosos y animados con soporte para promise | Necesitas un bundle muy pequeño |
| shadcn Form | Quieres visualización de errores integrada con estilos consistentes | Estás construyendo un sistema de diseño personalizado |
FAQs
¿Cómo estableces un error a nivel de formulario que no esté vinculado a ningún campo específico?
setError("root", { message: "Credenciales inválidas" });- Accede a él via
errors.root?.message - Usa esto para errores del servidor como "Credenciales inválidas" o "Error de red"
¿Cuál es la diferencia entre errores a nivel de campo y errores a nivel de formulario?
- Los errores a nivel de campo aparecen junto al input al que pertenecen (p. ej., "Email inválido")
- Los errores a nivel de formulario usan
setError("root", ...)y aparecen en un banner en la parte superior - Usa nivel de campo para validación; usa nivel de formulario para errores del servidor o errores entre campos
¿Cuándo deberías usar un toast en lugar de un mensaje de error inline?
- Usa toasts para retroalimentación de éxito y errores transitorios del servidor
- Usa errores inline para validación de campos para que los usuarios vean qué campo necesita corrección
- Los toasts desaparecen, lo que los hace inadecuados para errores de validación accionables
¿Por qué el resumen de errores usa tabIndex={-1} y una ref?
tabIndex={-1}hace que el div sea enfocable via JavaScript pero no via la tecla Tab- La ref permite llamar a
.focus()programáticamente cuando los errores aparecen después del envío - Esto asegura que usuarios de teclado y lector de pantalla sean dirigidos a la lista de errores
Trampa: ¿Por qué el mode "all" causa que aparezcan errores en el primer render?
mode: "all"valida en cada cambio, incluyendo el montaje inicial- Los errores aparecen antes de que el usuario haya interactuado con el formulario
- Arreglo: usa
mode: "onBlur"omode: "onSubmit"para diferir la validación hasta la interacción
Trampa: ¿Qué sucede si usas múltiples elementos role="alert" simultáneamente?
- Cada elemento con
role="alert"desencadena un anuncio inmediato del lector de pantalla - Múltiples alertas simultáneas abruman a los usuarios con discurso superpuesto
- Arreglo: usa un único resumen de errores con
role="alert"o usaaria-live="polite"en errores individuales
¿Cómo maneja el componente FieldError el renderizado condicional?
function FieldError({ message }: { message?: string }) {
if (!message) return null;
return <p role="alert" className="text-sm text-red-600">{message}</p>;
}- Retorna null cuando no hay mensaje, no renderizando nada
- Usa
role="alert"para anunciar el error a lectores de pantalla cuando aparece
¿Cómo tipas el parámetro error para un componente de mensaje de error reutilizable en TypeScript?
import type { FieldError } from "react-hook-form";
function ErrorMessage({ error }: { error?: FieldError }) {
if (!error) return null;
return <p>{error.message}</p>;
}- Importa
FieldErrorde react-hook-form para el tipo correcto
¿Cómo tipas las claves de error de formulario para iterarlas de manera segura en TypeScript?
type FormErrors = Record<keyof FormData, FieldError | undefined>;- Esto limita las claves de error solo a nombres de campo válidos de tu esquema de formulario
¿Cómo se auto-descarta el componente Toast después de un timeout?
- Un
useEffectestablece unsetTimeoutpara llamar aonClosedespués de 5 segundos - La función de limpieza borra el timer si el componente se desmonta temprano
- El toast usa
role="status"yaria-live="polite"para anuncios accesibles
¿Qué es la librería de toast Sonner y cuándo la usarías sobre un toast personalizado?
import { toast } from "sonner";
toast.success("¡Guardado exitosamente!");
toast.error("Algo salió mal", { description: err.message });- Sonner proporciona toasts animados y conscientes de promise de caja
- Úsalo cuando quieras pulido sin construir tu propio sistema de toast
¿Por qué siempre deberías emparejar un borde rojo con un mensaje de error de texto?
- Un borde rojo solo falla requisitos de contraste WCAG para usuarios con daltonismo
- Los mensajes de texto comunican el error independientemente de la percepción del color
- Combinar ambos asegura que todos los usuarios entiendan qué campo tiene un problema
Relacionado
- Accesibilidad de Formularios — atributos ARIA y gestión de enfoque
- Patrones de Formulario Básico — patrones comunes de formulario
- shadcn Form — integración de visualización de errores de shadcn
- RHF + Zod — mapeo de errores del resolver