Suggest an editImprove this articleRefine the answer for “Form validation errors”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Form validation errors** are shown next to the field (inline) for a specific error and above the form (general) for shared or server-side errors, taking into account `blur`, `submit`, and a11y attributes (`aria-invalid`, `aria-describedby`, `role="alert"`). **Key point:** keep `values`, `touched`, and `errors` in separate state, move focus to the first invalid field after submit, and map server errors to specific fields whenever possible.Shown above the full answer for quick recall.Answer (EN)Image## UX validation principles - **Where to show it:** - *Field-level (inline)* under the input for a specific error. - *Form-level (general)* at the top of the form for shared/server errors. - **When to show it:** - On `blur` (initial feedback) and on `submit`; - On `change` - only if the field is already `touched/dirty`. - **Focus and accessibility (a11y):** - Move focus to the first invalid input after submit. - `aria-invalid="true"`, `aria-describedby="error-id"`, an error container with `role="alert"` or `aria-live="polite"`. - **Types of errors:** client-side (schema), server-side (for example, "email already taken"). Map server errors to fields, otherwise use a general banner. - **State:** keep `values`, `touched`, `errors` separate. - **Copy:** brief, in plain human language, not "validate value with pattern 1". - **Do not block input:** disable the submit button only while loading; on errors, allow retrying. --- ## Option A: A lightweight custom validation (with Zod) ```javascript import { useEffect, useMemo, useRef, useState } from "react"; import { z } from "zod"; const schema = z.object({ email: z.string().email("Enter a valid email"), password: z.string().min(8, "At least 8 characters"), }); type Values = z.infer<typeof schema>; type Errors = Partial<Record<keyof Values, string>>; export function LoginForm() { const [values, setValues] = useState<Values>({ email: "", password: "" }); const [touched, setTouched] = useState<Record<string, boolean>>({}); const [errors, setErrors] = useState<Errors>({}); const [formError, setFormError] = useState<string | null>(null); const [loading, setLoading] = useState(false); const firstErrorRef = useRef<HTMLInputElement | null>(null); const validate = (v: Values): Errors => { const res = schema.safeParse(v); if (res.success) return {}; const e: Errors = {}; res.error.issues.forEach(i => { const path = i.path[0] as keyof Values; e[path] = i.message; }); return e; }; // validate on change of already touched fields useEffect(() => { const e = validate(values); setErrors(e); }, [values]); const onChange = (name: keyof Values) => (e: React.ChangeEvent<HTMLInputElement>) => { setValues(v => ({ ...v, [name]: e.target.value })); }; const onBlur = (name: keyof Values) => () => setTouched(t => ({ ...t, [name]: true })); const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); setTouched({ email: true, password: true }); const eMap = validate(values); setErrors(eMap); setFormError(null); if (Object.keys(eMap).length) { // focus the first invalid field const firstField = document.querySelector<HTMLInputElement>('[aria-invalid="true"]'); firstField?.focus(); return; } try { setLoading(true); // simulate a request const ok = await fakeLogin(values); if (!ok) { // server error mapped to a field, or a general one setErrors(prev => ({ ...prev, email: "This email is already registered" })); } } catch (err) { setFormError("Could not submit the form. Please try again later."); } finally { setLoading(false); } }; const emailInvalid = !!errors.email && touched.email; const passInvalid = !!errors.password && touched.password; return ( <form onSubmit={onSubmit} noValidate> {formError && ( <div role="alert" className="mb-3 text-red-600"> {formError} </div> )} <label> Email <input type="email" value={values.email} onChange={onChange("email")} onBlur={onBlur("email")} aria-invalid={emailInvalid || undefined} aria-describedby={emailInvalid ? "email-error" : undefined} /> </label> {emailInvalid && ( <div id="email-error" role="alert" className="text-red-600 text-sm"> {errors.email} </div> )} <label> Password <input type="password" value={values.password} onChange={onChange("password")} onBlur={onBlur("password")} aria-invalid={passInvalid || undefined} aria-describedby={passInvalid ? "password-error" : undefined} /> </label> {passInvalid && ( <div id="password-error" role="alert" className="text-red-600 text-sm"> {errors.password} </div> )} <button type="submit" disabled={loading}> {loading ? "Submitting..." : "Log in"} </button> </form> ); } async function fakeLogin(_: { email: string; password: string }) { await new Promise(r => setTimeout(r, 600)); return false; // return "email is taken" } ``` **What is good here:** touched logic, inline errors, a general banner, focus on the first invalid field, mapping server errors. --- ## Option B: React Hook Form + Zod (minimum code, maximum features) ```javascript import { useForm } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; const schema = z.object({ email: z.string().email("Enter a valid email"), password: z.string().min(8, "At least 8 characters"), }); type FormData = z.infer<typeof schema>; export function RHFLoginForm() { const { register, handleSubmit, formState, setError } = useForm<FormData>({ resolver: zodResolver(schema), mode: "onBlur", // blur + onSubmit }); const { errors, isSubmitting } = formState; const onSubmit = async (data: FormData) => { try { const ok = await fakeLogin(data); if (!ok) { // server error mapped to a specific field setError("email", { type: "server", message: "This email is already registered" }); } } catch { // a general form error setError("root", { type: "server", message: "The service is unavailable. Please try again later." }); } }; return ( <form onSubmit={handleSubmit(onSubmit)} noValidate> {errors.root && ( <div role="alert" className="mb-3 text-red-600"> {errors.root.message} </div> )} <label> Email <input type="email" {...register("email")} aria-invalid={!!errors.email || undefined} aria-describedby={errors.email ? "email-error" : undefined} /> </label> {errors.email && ( <div id="email-error" role="alert" className="text-red-600 text-sm"> {errors.email.message} </div> )} <label> Password <input type="password" {...register("password")} aria-invalid={!!errors.password || undefined} aria-describedby={errors.password ? "password-error" : undefined} /> </label> {errors.password && ( <div id="password-error" role="alert" className="text-red-600 text-sm"> {errors.password.message} </div> )} <button type="submit" disabled={isSubmitting}> {isSubmitting ? "Submitting..." : "Log in"} </button> </form> ); } ``` **Why this is convenient:** RHF gives you `touched/dirty`, focus management, `setError` for server responses, easy integration with Zod/Yup, and it does not trigger unnecessary re-renders. --- ### Tips from practice - **Server -> field:** the server returned `{ field:"email", message:"taken" }` -> call `setError("email", { message })`. - **Server -> general banner:** if there is no field, put it in `errors.root`. - **Many errors:** make a "summary" at the top (a list of anchor links to the fields). - **i18n:** store error codes, keep the text in a dictionary. - **Long forms:** show errors section by section; auto-scroll to the first error. - **Statuses:** `isSubmitting`, `isValidating`, a loader on the button; do not disable fields on errors.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.