Form Validation at Scale
A login box with two fields is easy. Real projects have registration forms, KYC forms and checkout pages with ten or twenty fields, and the same rules must hold everywhere: in the browser for quick feedback, and on the server, which must never trust the browser. Writing if statements by hand for every field quickly becomes a mess.
In this lesson you will return field-level errors from a React 19 action without losing what the user typed, keep all rules in one place, check fields when the user leaves them, describe rules with a schema using Zod, and see how React Hook Form handles very large forms. It builds on Form Actions & useActionState.
Field errors from an action
An action can return an object with one message per field. But there is a trap: after an action finishes, React resets the form's uncontrolled inputs, even when you returned errors. The user who typed eight fields correctly and one wrongly would see all eight emptied. The fix is to return the submitted values too and use them as each input's defaultValue:
import { useActionState } from "react";
import { createRoot } from "react-dom/client";
function validate(values) {
const errors = {};
if (values.name.trim().length < 3) errors.name = "Enter at least 3 letters.";
if (!/^\S+@\S+\.\S+$/.test(values.email)) errors.email = "Enter a valid email address.";
if (!/^[6-9]\d{9}$/.test(values.phone)) errors.phone = "Enter a 10-digit mobile number.";
return errors;
}
async function register(previous, formData) {
const values = Object.fromEntries(formData); // { name, email, phone } as strings
const errors = validate(values);
if (Object.keys(errors).length > 0) {
console.log(`Rejected: ${Object.keys(errors).join(", ")}`);
return { errors, values }; // send the values back so the inputs keep them
}
console.log(`Registered ${values.name}`);
return { errors: {}, values: { name: "", email: "", phone: "" }, done: `Welcome, ${values.name}!` };
}
const empty = { errors: {}, values: { name: "", email: "", phone: "" } };
function Input({ label, name, state, ...rest }) {
const error = state.errors[name];
return (
<p>
<label>
{label}{" "}
<input name={name} defaultValue={state.values[name]} aria-invalid={error ? true : undefined} {...rest} />
</label>
{error && <small style={{ color: "#b91c1c" }}> {error}</small>}
</p>
);
}
function RegisterForm() {
const [state, formAction, isPending] = useActionState(register, empty);
return (
<form action={formAction} noValidate>
<Input label="Name" name="name" state={state} />
<Input label="Email" name="email" type="email" state={state} />
<Input label="Mobile" name="phone" inputMode="numeric" state={state} />
<button disabled={isPending}>Register</button>
{state.done && <p style={{ color: "green" }}>{state.done}</p>}
</form>
);
}
createRoot(document.getElementById("root")).render(<RegisterForm />);Enter "Ab" as the name, "kabir@example.com" as the email and "98765" as the mobile, then press "Register". The console prints:
Rejected: name, phone
The two errors appear next to their fields, and all three inputs still show what you typed, because the new render sets each defaultValue to the submitted value before React resets the form. Fix the fields and submit again: the form clears and the welcome message appears. Remove defaultValue from Input and try again to see the trap for yourself.
One set of rules, checked on blur
Errors that appear only after pressing submit feel late on long forms. Errors that appear on every keystroke feel aggressive ("Invalid email" after typing one letter). A popular middle way is to check a field when the user leaves it (the blur event), and from then on re-check it as they type. The key is to write the rules once and use them in both places:
import { useState } from "react";
import { createRoot } from "react-dom/client";
const rules = {
pincode: (v) => (/^[1-9]\d{5}$/.test(v) ? "" : "PIN code must be 6 digits and not start with 0."),
city: (v) => (v.trim() ? "" : "City is required."),
};
function AddressForm() {
const [values, setValues] = useState({ pincode: "", city: "" });
const [touched, setTouched] = useState({});
const errors = Object.fromEntries(Object.entries(rules).map(([field, check]) => [field, check(values[field])]));
const visibleError = (field) => (touched[field] ? errors[field] : "");
function handleChange(e) {
setValues({ ...values, [e.target.name]: e.target.value });
}
function handleBlur(e) {
setTouched({ ...touched, [e.target.name]: true });
}
function handleSubmit(e) {
e.preventDefault();
setTouched({ pincode: true, city: true }); // show every error on submit
if (Object.values(errors).every((msg) => msg === "")) console.log(`Saved: ${values.city} ${values.pincode}`);
}
return (
<form onSubmit={handleSubmit} noValidate>
{["pincode", "city"].map((field) => (
<p key={field}>
<label>
{field === "pincode" ? "PIN code" : "City"}{" "}
<input name={field} value={values[field]} onChange={handleChange} onBlur={handleBlur} />
</label>
<small style={{ color: "#b91c1c" }}> {visibleError(field)}</small>
</p>
))}
<button>Save address</button>
</form>
);
}
createRoot(document.getElementById("root")).render(<AddressForm />);Type "0124" in the PIN code box and nothing complains yet. Press Tab: the error appears. Change the value to "302001" and it disappears while you type. Errors are derived from the values on every render, never stored separately, so they can never be out of date. Only touched decides whether to show them.
Schemas with Zod
As forms grow, rules repeat across pages and between the browser and the server. A schema describes the shape of valid data in one object. Zod (version 4) is the most widely used schema library in React and TypeScript projects. It validates the data and also converts it, for example turning the string "19" from a form into the number 19:
import * as z from "zod";
export const Registration = z.object({
name: z.string().trim().min(3, "Enter at least 3 letters."),
email: z.email("Enter a valid email address."),
age: z.coerce.number().int().min(16, "You must be 16 or older."),
course: z.enum(["React", "Python", "SQL"], "Choose a course."),
});
const result = Registration.safeParse({ name: " Ab ", email: "abc", age: "15", course: "Java" });
console.log(result.success);
console.log(z.flattenError(result.error).fieldErrors);false
{
name: [ 'Enter at least 3 letters.' ],
email: [ 'Enter a valid email address.' ],
age: [ 'You must be 16 or older.' ],
course: [ 'Choose a course.' ]
}safeParse never throws: it returns { success: true, data } with the cleaned data, or { success: false, error }. z.flattenError turns the error into one array of messages per field, which fits the errors object from the first example. Inside an action, Registration.safeParse(Object.fromEntries(formData)) replaces the hand-written validate function. In TypeScript, z.infer<typeof Registration> gives you the matching type for free, so the rules and the types can never disagree.
React Hook Form for very large forms
React Hook Form (version 7) is a library built for big forms. It keeps inputs uncontrolled, reading their values from the DOM, so typing in one field does not re-render the whole form. register connects an input, handleSubmit validates before calling your function, and formState.errors holds the messages. With the @hookform/resolvers package it can use the same Zod schema:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Registration } from "./schemas/registration";
export function RegistrationForm({ onSave }) {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(Registration),
defaultValues: { name: "", email: "", age: "", course: "React" },
});
async function onSubmit(data) {
const res = await onSave(data); // data is already validated and converted (age is a number)
if (res.emailTaken) setError("email", { message: "This email is already registered." });
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label>Name <input {...register("name")} aria-invalid={errors.name ? true : undefined} /></label>
{errors.name && <p role="alert">{errors.name.message}</p>}
<label>Email <input {...register("email")} /></label>
{errors.email && <p role="alert">{errors.email.message}</p>}
<label>Age <input {...register("age")} inputMode="numeric" /></label>
{errors.age && <p role="alert">{errors.age.message}</p>}
<button disabled={isSubmitting}>Register</button>
</form>
);
}setError shows how to display an error that only the server can know, such as an email that is already registered. You do not need a library for every form: React 19 actions with a schema cover most forms well. React Hook Form shines on long, highly interactive forms with field arrays (for example "add another family member") and many conditional fields.
Common mistakes
- Losing typed values after an error. React resets uncontrolled fields after an action; return the values and use them as
defaultValue. - Shouting on the first keystroke. Validate on blur or submit first, then live while the user fixes the field.
- Storing errors in state that could be derived. Compute them from the values so they never go stale.
- Rules copied into several components. Keep them in one function or schema and import it.
- Trusting client-side checks. The server must validate every request again.
- Errors only shown in colour. Use text and connect it with
aria-describedbyoraria-invalid, as in Accessibility in React.
Key points
- Return
{ errors, values }from actions and use the values asdefaultValueso a failed submit keeps the user's input. - Write each rule once and reuse it on blur, on submit and on the server.
- Derive errors from values; track only which fields were touched.
- Zod schemas validate and convert data;
safeParseplusz.flattenErrorgives per-field messages. - React Hook Form keeps large forms fast with uncontrolled inputs and works with Zod through
zodResolver. - Server-only errors, such as a taken email, are shown next to the field with the same error display.
Exercise
Build a "Book a cricket net" form with useActionState. Fields: name (at least 2 letters), players (a whole number from 2 to 12) and date (required). Return field errors and the submitted values when something is wrong, keep the typed values with defaultValue, and return "Net booked for NAME's group of N" on success. Log the names of the rejected fields like the first example.
Show solution
import { useActionState } from "react";
import { createRoot } from "react-dom/client";
const blank = { name: "", players: "", date: "" };
async function book(previous, formData) {
const values = Object.fromEntries(formData);
const errors = {};
const players = Number(values.players);
if (values.name.trim().length < 2) errors.name = "Enter at least 2 letters.";
if (!Number.isInteger(players) || players < 2 || players > 12) errors.players = "Choose 2 to 12 players.";
if (!values.date) errors.date = "Pick a date.";
if (Object.keys(errors).length) {
console.log(`Rejected: ${Object.keys(errors).join(", ")}`);
return { errors, values };
}
return { errors: {}, values: blank, message: `Net booked for ${values.name}'s group of ${players}` };
}
function NetBooking() {
const [state, formAction] = useActionState(book, { errors: {}, values: blank });
const field = (name, label, type = "text") => (
<p>
<label>{label} <input name={name} type={type} defaultValue={state.values[name]} /></label>
{state.errors[name] && <small style={{ color: "#b91c1c" }}> {state.errors[name]}</small>}
</p>
);
return (
<form action={formAction} noValidate>
{field("name", "Captain's name")}
{field("players", "Players", "number")}
{field("date", "Date", "date")}
<button>Book net</button>
{state.message && <p>{state.message}</p>}
</form>
);
}
createRoot(document.getElementById("root")).render(<NetBooking />);On a failed submit the action returns the values, so every input keeps its text after React resets the form. Number.isInteger rejects both decimals and empty input, because Number("") is 0, which is below 2.
Quick Quiz
1. After a React 19 form action returns validation errors, why might the inputs become empty?
Returning the values and rendering them as defaultValue keeps what the user typed.
2. Why show a field's error only after it has been touched?
On-blur validation gives timely feedback without complaining on the first keystroke.
3. What does Registration.safeParse(data) return when the data is invalid?
safeParse never throws; parse is the version that throws.
4. Why must the server validate again even if the React form validates?
Client-side validation is for convenience; the server is the real gatekeeper.
Learn React with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
