Form Error Display
Field errors, form errors, toast notifications, and inline messages — patterns for showing validation feedback to users.
Recipe
Quick-reference recipe card — copy-paste ready.
"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("Invalid email"),
password: z.string().min(8, "At least 8 characters"),
});
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: "Invalid credentials" });
})}>
{/* Form-level error */}
{errors.root && (
<div role="alert" className="rounded bg-red-50 p-3 text-sm text-red-700">
{errors.root.message}
</div>
)}
{/* Field-level error */}
<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">Submit</button>
</form>
);
}When to reach for this: Every form needs error display. Choose the pattern based on error type — field-level for validation, form-level for server errors, toast for async results.
Working Example
"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, "Name is required"),
email: z.string().email("Please enter a valid email address"),
phone: z.string().regex(/^\+?[\d\s-()]+$/, "Invalid phone number").optional().or(z.literal("")),
message: z.string().min(20, "Message must be at least 20 characters"),
});
type FormData = z.infer<typeof Schema>;
// Inline error component with animation
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>
);
}
// Form-level error banner
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>
);
}
// Toast notification
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">
Close
</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",
});
// Focus error summary when errors appear
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("Server error");
reset();
setToast("Message sent successfully!");
} catch {
setError("root", { message: "Failed to send. Please try again." });
}
}
const errorCount = Object.keys(errors).filter((k) => k !== "root").length;
return (
<>
<form onSubmit={handleSubmit(onSubmit)} className="max-w-md space-y-4" noValidate>
{/* Error summary at the top */}
{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">
Please fix {errorCount} {errorCount === 1 ? "error" : "errors"}:
</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>
)}
{/* Server error */}
{errors.root && <FormBanner message={errors.root.message!} type="error" />}
<div>
<label htmlFor="name" className="block text-sm font-medium">
Name <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">Phone</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">
Message <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 ? "Sending..." : "Send Message"}
</button>
</form>
{toast && <Toast message={toast} onClose={() => setToast(null)} />}
</>
);
}What this demonstrates:
- Error summary banner with count and list
- Per-field inline errors with animation
- Form-level server errors via
setError("root") - Toast for success feedback
- Focus management for error summary
aria-invalidandrole="alert"for accessibility
Deep Dive
How It Works
- RHF's
errorsobject mirrors the form schema shape — accesserrors.fieldName.message setError("root", ...)sets a non-field error accessible viaerrors.rootrole="alert"causes screen readers to announce the error immediatelyaria-invalid={true}marks the input as invalid for assistive technologyaria-describedbylinks the input to its error message element- Error summary with
tabIndex={-1}andfocus()ensures keyboard users notice errors
Variations
Toast with Sonner:
import { toast } from "sonner";
async function onSubmit(data: FormData) {
try {
await submitForm(data);
toast.success("Saved successfully!");
} catch (err) {
toast.error("Something went wrong", { description: err.message });
}
}Error boundary for unexpected errors:
function FormErrorBoundary({ children }: { children: React.ReactNode }) {
return (
<ErrorBoundary
fallback={
<div role="alert" className="rounded bg-red-50 p-4 text-red-800">
Something went wrong. Please refresh and try again.
</div>
}
>
{children}
</ErrorBoundary>
);
}Inline hints that become errors:
function PasswordField({ register, error }: { register: any; error?: FieldError }) {
const value = useWatch({ name: "password" });
const rules = [
{ test: (v: string) => v.length >= 8, label: "8+ characters" },
{ test: (v: string) => /[A-Z]/.test(v), label: "Uppercase letter" },
{ test: (v: string) => /\d/.test(v), label: "Number" },
];
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>
);
}TypeScript Notes
// FieldError type from 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>;
}
// Typed error keys
type FormErrors = Record<keyof FormData, FieldError | undefined>;Gotchas
-
Error flash on first render — If
mode: "all", errors show before the user interacts. Fix: Usemode: "onBlur"ormode: "onSubmit"and only show errors after first interaction. -
Toast vs inline for validation — Toasts disappear, making it hard to find which field failed. Fix: Use inline errors for validation; reserve toasts for success messages and server errors.
-
Screen reader announcements — Multiple
role="alert"elements all announcing at once overwhelms users. Fix: Use a single error summary withrole="alert"and individual errors without it, or usearia-live="polite". -
Error border without message — A red border alone does not help colorblind users. Fix: Always pair visual indicators with text messages.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Native HTML validation | You only need required and type checks | You need custom error messages or complex rules |
| react-hot-toast | Lightweight toast with minimal dependencies | You need rich toast features (promise, custom render) |
| Sonner | You want beautiful, animated toasts with promise support | You need a very small bundle |
| shadcn Form | You want built-in error display with consistent styling | You are building a custom design system |
FAQs
How do you set a form-level error that is not tied to any specific field?
setError("root", { message: "Invalid credentials" });- Access it via
errors.root?.message - Use this for server errors like "Invalid credentials" or "Network error"
What is the difference between field-level errors and form-level errors?
- Field-level errors appear next to the input they belong to (e.g., "Invalid email")
- Form-level errors use
setError("root", ...)and appear in a banner at the top - Use field-level for validation; use form-level for server or cross-field errors
When should you use a toast instead of an inline error message?
- Use toasts for success feedback and transient server errors
- Use inline errors for field validation so users can see which field needs fixing
- Toasts disappear, making them unsuitable for actionable validation errors
Why does the error summary use tabIndex={-1} and a ref?
tabIndex={-1}makes the div focusable via JavaScript but not via Tab key- The ref allows calling
.focus()programmatically when errors appear after submission - This ensures keyboard and screen reader users are directed to the error list
Gotcha: Why does mode "all" cause errors to flash on first render?
mode: "all"validates on every change, including initial mount- Errors appear before the user has interacted with the form
- Fix: use
mode: "onBlur"ormode: "onSubmit"to defer validation until interaction
Gotcha: What happens if you use multiple role="alert" elements simultaneously?
- Each element with
role="alert"triggers an immediate screen reader announcement - Multiple simultaneous alerts overwhelm users with overlapping speech
- Fix: use a single error summary with
role="alert"or usearia-live="polite"on individual errors
How does the FieldError component handle conditional rendering?
function FieldError({ message }: { message?: string }) {
if (!message) return null;
return <p role="alert" className="text-sm text-red-600">{message}</p>;
}- Returns null when there is no message, rendering nothing
- Uses
role="alert"to announce the error to screen readers when it appears
How do you type the error parameter for a reusable error message component in TypeScript?
import type { FieldError } from "react-hook-form";
function ErrorMessage({ error }: { error?: FieldError }) {
if (!error) return null;
return <p>{error.message}</p>;
}- Import
FieldErrorfrom react-hook-form for the correct type
How do you type form error keys to iterate over them safely in TypeScript?
type FormErrors = Record<keyof FormData, FieldError | undefined>;- This constrains error keys to only valid field names from your form schema
How does the Toast component auto-dismiss after a timeout?
- A
useEffectsets asetTimeoutto callonCloseafter 5 seconds - The cleanup function clears the timer if the component unmounts early
- The toast uses
role="status"andaria-live="polite"for accessible announcements
What is the Sonner toast library and when would you use it over a custom toast?
import { toast } from "sonner";
toast.success("Saved successfully!");
toast.error("Something went wrong", { description: err.message });- Sonner provides animated, promise-aware toasts out of the box
- Use it when you want polish without building your own toast system
Why should you always pair a red border with a text error message?
- A red border alone fails WCAG contrast requirements for colorblind users
- Text messages convey the error regardless of color perception
- Combining both ensures all users understand which field has a problem
Related
- Form Accessibility — ARIA attributes and focus management
- Form Patterns Basic — common form patterns
- shadcn Form — shadcn error display integration
- RHF + Zod — resolver error mapping