Module 5: Forms, Styling and Routing
Form Validation and React Hook Form
Validation timing, accessible error messages, a hand-rolled validator, and how React Hook Form with a schema removes the boilerplate.
Validation looks like a small feature and turns into a large one. There's the logic (what counts as valid), the timing (when do you tell the user), and the presentation (how do you show it so it's usable and accessible). Let's cover each, first by hand, then with a library.
Timing#
You have three options for when to show an error.
- On change. Errors appear while typing. Annoying for something like email, since "a" is invalid until the user finishes.
- On blur. When the user leaves the field. Usually the best balance.
- On submit. Show everything at once. Simple, but feedback comes late.
A common pattern is validate on blur, then on every change after a field has been touched, and always on submit.
Whatever you pick, validate again on the server. Client-side validation is for user experience. Anyone can bypass it. The server is where validity is actually enforced.
Validating by hand#
First, a validation function that returns an object of error messages. It's pure and easy to test.
function validate(values) {
const errors = {};
if (!values.name.trim()) {
errors.name = "Name is required.";
}
if (!values.email) {
errors.email = "Email is required.";
} else if (!/^\S+@\S+\.\S+$/.test(values.email)) {
errors.email = "That doesn't look like an email address.";
}
if (values.password.length < 8) {
errors.password = "Use at least 8 characters.";
}
return errors;
}
Then the form:
function Signup() {
const [values, setValues] = useState({ name: "", email: "", password: "" });
const [touched, setTouched] = useState({});
const errors = validate(values); // derived, not stored
function handleChange(e) {
setValues((v) => ({ ...v, [e.target.name]: e.target.value }));
}
function handleBlur(e) {
setTouched((t) => ({ ...t, [e.target.name]: true }));
}
function handleSubmit(e) {
e.preventDefault();
setTouched({ name: true, email: true, password: true }); // reveal everything
if (Object.keys(errors).length > 0) return;
console.log("send", values);
}
const show = (field) => touched[field] && errors[field];
return (
<form onSubmit={handleSubmit} noValidate>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
value={values.email}
onChange={handleChange}
onBlur={handleBlur}
aria-invalid={Boolean(show("email"))}
aria-describedby={show("email") ? "email-error" : undefined}
/>
{show("email") && <p id="email-error" role="alert">{errors.email}</p>}
{/* name and password fields follow the same pattern */}
<button type="submit">Sign up</button>
</form>
);
}
Look at what's derived and what's stored. errors isn't state, it's computed from values every render. Only touched is extra state, because it's information about the user's behavior that can't be computed from the values.
A few accessibility details, because they're easy and they matter:
aria-invalidtells assistive tech the field has a problem.aria-describedbyconnects the field to its error message so it gets read out.role="alert"announces the message when it appears.noValidateon the form turns off the browser's built-in bubbles so you control the experience (you can still use built-inrequiredandtype="email"for the semantics).
When the boilerplate gets old#
That's maybe 40 lines for three fields, and every form repeats it. Multiply by a registration page, profile page, checkout, and you see why people reach for React Hook Form (RHF).
npm install react-hook-form
How RHF works#
RHF mostly uses uncontrolled inputs. It attaches a ref to each field through register, reads values from the DOM when needed, and only re-renders the parts that have to. A big form doesn't re-render on every keystroke, which is its main selling point.
Controlled form React Hook Form
─────────────── ───────────────
keystroke keystroke
│ │
▼ ▼
onChange calls setState the browser updates the input itself
│ (the DOM owns the value, no re-render)
▼ │
whole form re-renders ▼ on submit
with the new value handleSubmit reads values from the refs
│
▼
valid? yes: onSubmit(values)
no: set errors, re-render only what changed
import { useForm } from "react-hook-form";
function Signup() {
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm({
defaultValues: { name: "", email: "", password: "" },
mode: "onBlur", // when to validate
});
async function onSubmit(values) {
await fetch("/api/signup", { method: "POST", body: JSON.stringify(values) });
reset();
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="name">Name</label>
<input id="name" {...register("name", { required: "Name is required." })} />
{errors.name && <p role="alert">{errors.name.message}</p>}
<label htmlFor="email">Email</label>
<input
id="email"
{...register("email", {
required: "Email is required.",
pattern: { value: /^\S+@\S+\.\S+$/, message: "Enter a valid email." },
})}
/>
{errors.email && <p role="alert">{errors.email.message}</p>}
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
{...register("password", { required: true, minLength: { value: 8, message: "At least 8 characters." } })}
/>
{errors.password && <p role="alert">{errors.password.message}</p>}
<button disabled={isSubmitting}>{isSubmitting ? "Saving..." : "Sign up"}</button>
</form>
);
}
register("email", rules) returns an object (name, onChange, onBlur, ref), and spreading it onto the input wires everything up. handleSubmit runs validation first and only calls your onSubmit with clean values if everything passes. isSubmitting is true while an async onSubmit is pending, which makes disabling the button trivial.
Schemas instead of inline rules#
Inline rules get messy for anything beyond simple cases. A schema library such as Zod lets you define the shape and rules once, and also reuse them on the server.
npm install zod @hookform/resolvers
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z
.object({
email: z.string().min(1, "Email is required.").email("Enter a valid email."),
password: z.string().min(8, "At least 8 characters."),
confirm: z.string(),
})
.refine((d) => d.password === d.confirm, {
message: "Passwords don't match.",
path: ["confirm"], // attach the error to this field
});
function Signup() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
});
return (
<form onSubmit={handleSubmit((values) => console.log(values))} noValidate>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register("password")} />
{errors.password && <p>{errors.password.message}</p>}
<input type="password" {...register("confirm")} />
{errors.confirm && <p>{errors.confirm.message}</p>}
<button>Sign up</button>
</form>
);
}
The rules now live in one object. The cross-field check ("passwords match") is easy to express. Check the current docs for the resolver package, since the Zod and resolver versions need to be compatible when you install them.
Server errors#
When the server rejects a submission (email already used), show it on the right field with setError:
const { setError } = useForm();
try {
await api.signup(values);
} catch (err) {
if (err.status === 409) setError("email", { message: "That email is already registered." });
else setError("root", { message: "Something went wrong. Try again." });
}
Where people get burned#
- Only validating on the client.
- Showing "required" errors on every empty field the moment the page loads. Wait until the user has touched them.
- Messages like "Invalid input" that don't say what to fix.
- Forgetting labels and
ariaattributes, which makes errors invisible to screen readers. - Mixing controlled
valueprops with RHF'sregisteron the same field. - Not setting
defaultValues, so a field startsundefinedand RHF can't reset properly.
Practice#
- Write the hand-rolled version for a three-field form and unit test the
validatefunction without any React. - Rebuild the same form with React Hook Form and compare line counts and re-render counts (log inside the component).
- Move the rules into a Zod schema and add a "confirm password" field with a cross-field check.
- Simulate a 409 response from the server and display it on the email field.