React form validation gets messy when every layer thinks it owns the truth. The browser checks one thing. React state checks another. The API rejects something else. Then a user pastes a phone number with a space and the whole form acts surprised.
I prefer treating React form validation as a small contract. The client helps people avoid obvious mistakes. The schema describes what the app accepts. The server still checks the final payload because the browser is not a security boundary.
That sounds boring. Good. Forms should be boring.
React form validation should start with boring HTML
A lot of React forms become harder than they need to be because we skip what the browser already gives us. Inputs have type, required, minLength, maxLength, pattern, and native focus behavior. Those are not enough for a real app, but they are useful first filters.
For example, an email field should still use type="email". A password field that needs at least 12 characters should say so in the input. A number field should not pretend it is free text unless the product really needs that.
The browser layer catches the cheap mistakes:
- empty required fields
- malformed emails
- short passwords
- impossible numeric ranges
- missed checkbox consent
But I don't let this layer become the source of truth. Browser validation can be skipped. JavaScript can be disabled. Requests can be sent straight to the endpoint. And a clever user can send values your UI never allowed.
So the browser helps the user. It does not protect the system.
Keep field state separate from business rules
React makes it tempting to put every rule next to every input. That works for tiny forms, then quietly becomes a maintenance trap.
The trap looks like this: one component checks whether a username is long enough, another checks whether it only contains certain characters, and the API checks a third version of the same rule. Two months later, someone changes one rule and forgets the others.
I try to split form code into three parts:
| Layer | Job | Example |
|---|---|---|
| Input UI | Collect values and show errors | text fields, labels, helper text |
| Form state | Track dirty fields, touched fields, submit state | React Hook Form or local state |
| Validation schema | Decide whether the payload is valid | Zod schema shared with server code |
This split keeps the UI from becoming a pile of if statements. It also makes tests easier because the rules can be tested without rendering the entire form.
If you are already using React Hook Form, its docs show the same basic idea: register fields, collect values, handle errors, then submit only after validation runs. The library is useful because it reduces re-renders and avoids turning every keystroke into a state management event.
Use Zod for the contract, not for decoration
Zod is useful because it lets the app describe a payload once and infer TypeScript types from that schema. That matters when the same shape appears in the form, the API route, and the database insert.
A small signup schema might look like this:
import { z } from "zod";
export const signupSchema = z.object({
email: z.string().email(),
password: z.string().min(12),
displayName: z.string().min(2).max(40),
});
export type SignupInput = z.infer<typeof signupSchema>;The important part is not the syntax. The important part is where this file lives.
Put schemas in a shared module that both client and server code can import. Don't hide the schema inside a React component. Don't copy it into an API route. If the rule says a display name can be 40 characters, the same rule should apply everywhere.
But be careful with what you share. Some server-only checks should stay server-only: role assignment, account status, rate limits, invitation tokens, payment status, and anything that depends on private data.
A schema can validate the shape of a request. It should not leak business decisions that belong on the server.
Client validation is a UX feature
Client validation should make the form feel clear. It should not feel like a security guard with a clipboard.
Good client validation does a few things well:
- shows errors near the field that caused them
- waits until a field is touched before yelling
- preserves typed values after a failed submit
- focuses the first broken field
- uses plain language instead of schema jargon
Bad client validation punishes people while they type. It flashes red on the first character of an email field. It says Invalid string because the schema message leaked into the UI. It clears the whole form after one wrong value.
I like this rule: validation should reduce confusion, not add a new kind of confusion.
For most forms, validate on blur or submit. Live validation is fine for fields where the feedback is genuinely helpful, like password strength or username availability. Even then, debounce the check and make the pending state visible.
Server validation still gets final say
The server must validate the same payload again. No exception.
In a Next.js app, that might mean using the same Zod schema inside a Server Action or route handler. Next.js has a forms guide that shows the App Router path for form submission, including Server Actions. The exact framework is less important than the rule: parse the incoming data at the boundary.
A server handler should do something like this:
const result = signupSchema.safeParse(rawInput);
if (!result.success) {
return {
ok: false,
errors: result.error.flatten().fieldErrors,
};
}
const input = result.data;safeParse is usually nicer for request handling because it returns a result instead of throwing. That makes it easier to return field errors in a shape the UI can understand.
After schema validation passes, run server-only checks:
- is the email already used?
- is the invite token valid?
- is this IP sending too many requests?
- is the user allowed to change this resource?
Those checks don't belong in the client bundle. The user can see and modify client code. Treat it as a helpful interface, not a locked door.
If the form touches auth, read the Next.js authentication checklist before shipping it. Form bugs around sessions and redirects get expensive fast.
Normalize data before checking rules
A quiet source of form bugs is inconsistent normalization. One layer trims whitespace. Another layer doesn't. The database stores lowercase emails, but the UI compares mixed-case strings.
Normalize before validation when the normalization is harmless and expected. Trim display names. Lowercase emails if your product treats email addresses case-insensitively. Convert empty strings to undefined for optional fields if that is what your schema expects.
But don't normalize away meaning. A password should not be silently trimmed unless the product explicitly says spaces are ignored. A legal name may contain characters your quick regex did not expect. A phone number can be written in formats that look strange if you only test with one country.
This is where schemas need product judgment. A tighter rule is not always a better rule.
Error messages are part of the interface
Developers often spend time on the validation logic and then leave error messages as an afterthought. Users don't see your elegant schema. They see the sentence under the input.
Write errors like a human:
Use at least 12 characters.Enter an email address we can send mail to.That username is taken. Try another one.The invite link expired. Ask for a new link.
Avoid errors that blame the user or expose internals. Nobody needs to see Expected string, received null in a production form.
If the app has many forms, create a tiny error mapping layer. It can translate schema issues into product language without polluting the schema itself.
Test the schema and one real form path
You don't need a giant test suite for every input. You do need tests for the rules that matter.
Start with schema tests. Feed the schema values that should pass and values that should fail. Test boundaries: minimum length, maximum length, optional fields, empty strings, weird whitespace, and invalid types.
Then test one real form path in the browser or component layer:
- submit an empty form
- confirm errors show near the right fields
- fill valid data
- submit successfully
- confirm the UI handles a server-side error
That last step matters. A form that only works when everything succeeds is not done.
For broader testing habits, the test-driven TypeScript guide is a useful next stop. The same idea applies here: test the contract first, then test the user path.
A practical checklist before shipping
Before I ship a React form, I check this list:
- the form uses native input types where they fit
- field labels are real labels, not hint text
- the schema lives outside the component
- the server parses the payload again
- server-only checks stay on the server
- errors are readable and shown near the field
- failed submits preserve user input
- loading and disabled states are clear
- the first invalid field can receive focus
- the schema has boundary tests
This list is not fancy. It just catches the stuff people notice immediately when a form breaks.
Sources
- React documentation:
<input>reference - React Hook Form: Get Started
- Zod documentation: Intro
- Next.js documentation: Forms guide



