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 onsubmit; - On
change- only if the field is alreadytouched/dirty. - Focus and accessibility (a11y):
- Move focus to the first invalid input after submit.
aria-invalid="true",aria-describedby="error-id", an error container withrole="alert"oraria-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,errorsseparate. - 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" }-> callsetError("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 readyPremium
A concise answer to help you respond confidently on this topic during an interview.