Skip to main content

Form validation errors

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.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.