React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

formserrorstoastinline-errorsfield-errorsaccessibility

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-invalid and role="alert" for accessibility

Deep Dive

How It Works

  • RHF's errors object mirrors the form schema shape — access errors.fieldName.message
  • setError("root", ...) sets a non-field error accessible via errors.root
  • role="alert" causes screen readers to announce the error immediately
  • aria-invalid={true} marks the input as invalid for assistive technology
  • aria-describedby links the input to its error message element
  • Error summary with tabIndex={-1} and focus() 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: Use mode: "onBlur" or mode: "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 with role="alert" and individual errors without it, or use aria-live="polite".

  • Error border without message — A red border alone does not help colorblind users. Fix: Always pair visual indicators with text messages.

Alternatives

AlternativeUse WhenDon't Use When
Native HTML validationYou only need required and type checksYou need custom error messages or complex rules
react-hot-toastLightweight toast with minimal dependenciesYou need rich toast features (promise, custom render)
SonnerYou want beautiful, animated toasts with promise supportYou need a very small bundle
shadcn FormYou want built-in error display with consistent stylingYou 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" or mode: "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 use aria-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 FieldError from 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 useEffect sets a setTimeout to call onClose after 5 seconds
  • The cleanup function clears the timer if the component unmounts early
  • The toast uses role="status" and aria-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