Form Validation
Form validation is the process of checking user input against a set of rules — required fields, minimum lengths, valid email formats, matching passwords — and giving the user feedback before the data is submitted. React doesn’t ship a built-in validation library; instead, you build validation out of the same tools you already use for any controlled form: state, event handlers, and conditional rendering. Understanding this pattern well means you can validate anything, from a single email field to a multi-step signup wizard, without reaching for a third-party library.
Overview: How Form Validation Works in React
In React, form inputs are usually controlled components: the input’s value comes from state, and every keystroke fires an onChange handler that updates that state with setState. Because state changes trigger a re-render, the input always reflects the latest state, and — crucially for validation — you have a single source of truth you can inspect at any moment to decide whether the current value is valid.
Validation itself is just a plain JavaScript function: it takes the current values and returns either nothing (valid) or a description of what’s wrong (invalid). You store the validation results in a second piece of state, usually called errors, shaped as an object keyed by field name (e.g. { email: "Email is required." }). When errors.email is truthy, you conditionally render an error message next to the email field. When it’s falsy (or the key is absent), no message renders. This is the entire mechanism — everything else is a decision about when you call the validation function.
When to validate
- On submit — validate everything when the form is submitted, and block the submit if there are errors. Simple and avoids nagging the user while they’re still typing.
- On blur — validate a field once the user leaves it (the
onBlurevent). This gives feedback earlier without flashing errors on every keystroke. - On change — validate as the user types. Best reserved for fields already marked invalid (so the error can clear as soon as it’s fixed), rather than the first pass, which feels aggressive.
A common, user-friendly pattern combines all three: validate on blur (and on submit) to show errors, and validate on every change to fields that already have an error, so the error disappears the moment it’s fixed. You’ll see this pattern in the second example below, using a touched state object to track which fields the user has already interacted with.
Because validation is driven entirely by state and re-renders, it obeys the same rules as everything else in React: state updates are asynchronous and batched, so you should never read values or errors immediately after calling their setters and expect the new value — always compute the next value first (e.g. const nextValues = { ...values, [name]: value }) and pass that into your validation function directly, rather than trusting state that hasn’t re-rendered yet.
Syntax
There’s no special React API for validation — the “syntax” is really a small, repeatable shape you’ll reuse in nearly every form:
function validate(values) {
const errors = {};
if (/* rule fails */) {
errors.fieldName = "Helpful message.";
}
return errors;
}
function MyForm() {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
function handleChange(e) { /* update values */ }
function handleBlur(e) { /* run validate, update errors */ }
function handleSubmit(e) { /* e.preventDefault(); run validate; if none, submit */ }
return <form onSubmit={handleSubmit}>...</form>;
}
| Piece | Purpose |
|---|---|
values |
State object holding the current value of every field. |
errors |
State object holding the current error message (if any) per field. |
touched |
Optional state tracking which fields the user has already blurred, so errors don’t show prematurely. |
validate(values) |
Pure function that returns an errors object; contains no side effects, so it’s easy to test on its own. |
onChange |
Updates values; often also re-validates if the field is already marked invalid. |
onBlur |
Marks a field as touched and runs validate so the error can appear. |
onSubmit |
Calls e.preventDefault(), runs validate on everything, and only proceeds if the result is empty. |
Examples
Example 1: Validate on submit
The simplest approach: don’t bother the user while typing, just check everything when they hit submit.
import { useState } from "react";
function EmailForm() {
const [email, setEmail] = useState("");
const [error, setError] = useState("");
const [submitted, setSubmitted] = useState(false);
function validateEmail(value) {
if (!value.trim()) {
return "Email is required.";
}
const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!pattern.test(value)) {
return "Please enter a valid email address.";
}
return "";
}
function handleSubmit(e) {
e.preventDefault();
const validationError = validateEmail(email);
setError(validationError);
setSubmitted(validationError === "");
}
return (
<form onSubmit={handleSubmit} noValidate>
<label htmlFor="email">Email</label>
<input
id="email"
type="text"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setSubmitted(false);
}}
/>
{error && <p role="alert">{error}</p>}
{submitted && <p>Thanks, we'll be in touch!</p>}
<button type="submit">Subscribe</button>
</form>
);
}
export default EmailForm;
This renders a labeled text input and a Subscribe button. Submitting with an empty or malformed value shows a red-flagged error paragraph (role="alert" helps screen readers announce it); submitting a valid address hides the error and shows a thank-you message. Notice noValidate on the <form> — it disables the browser’s built-in HTML5 validation bubble so your React-driven message is the only one shown.
Example 2: Validate on blur, with a touched state
Real signup forms usually have several fields, and you don’t want to show every error the instant the page loads. This example tracks which fields the user has actually visited.
import { useState } from "react";
function validate(values) {
const errors = {};
if (!values.username.trim()) {
errors.username = "Username is required.";
} else if (values.username.length < 3) {
errors.username = "Username must be at least 3 characters.";
}
if (!values.password) {
errors.password = "Password is required.";
} else if (values.password.length < 8) {
errors.password = "Password must be at least 8 characters.";
}
return errors;
}
function SignupForm() {
const [values, setValues] = useState({ username: "", password: "" });
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
function handleChange(e) {
const { name, value } = e.target;
const nextValues = { ...values, [name]: value };
setValues(nextValues);
if (touched[name]) {
setErrors(validate(nextValues));
}
}
function handleBlur(e) {
const { name } = e.target;
setTouched({ ...touched, [name]: true });
setErrors(validate(values));
}
function handleSubmit(e) {
e.preventDefault();
const nextErrors = validate(values);
setErrors(nextErrors);
setTouched({ username: true, password: true });
if (Object.keys(nextErrors).length === 0) {
console.log("Submitting:", values);
}
}
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="username">Username</label>
<input
id="username"
name="username"
value={values.username}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.username && errors.username && (
<p role="alert">{errors.username}</p>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
value={values.password}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.password && errors.password && (
<p role="alert">{errors.password}</p>
)}
</div>
<button type="submit">Create account</button>
</form>
);
}
export default SignupForm;
Output:
Submitting: {username: "alice", password: "hunter2pass"}
Errors only appear once a field has been blurred (touched.username is true) or after a submit attempt, which also force-marks every field as touched. Because handleChange re-validates using nextValues — the value about to be set — rather than the (still-stale) values state, an error can clear the instant the user fixes it, without waiting for the next render.
Example 3: A reusable useFormValidation custom hook
Once you’ve written this pattern twice, it’s worth extracting into a custom hook so every form in your app shares the same logic.
import { useState } from "react";
function useFormValidation(initialValues, validate) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
function handleChange(e) {
const { name, value } = e.target;
setValues((prev) => ({ ...prev, [name]: value }));
}
function handleSubmit(onValid) {
return (e) => {
e.preventDefault();
const nextErrors = validate(values);
setErrors(nextErrors);
if (Object.keys(nextErrors).length === 0) {
onValid(values);
}
};
}
return { values, errors, handleChange, handleSubmit };
}
function validateSignup(values) {
const errors = {};
if (!values.email.includes("@")) {
errors.email = "Enter a valid email address.";
}
if (values.password.length < 8) {
errors.password = "Password must be at least 8 characters.";
}
if (values.confirmPassword !== values.password) {
errors.confirmPassword = "Passwords do not match.";
}
return errors;
}
function SignupWithHook() {
const { values, errors, handleChange, handleSubmit } = useFormValidation(
{ email: "", password: "", confirmPassword: "" },
validateSignup
);
function onValid(formValues) {
console.log("Account created for:", formValues.email);
}
return (
<form onSubmit={handleSubmit(onValid)} noValidate>
<input name="email" value={values.email} onChange={handleChange} placeholder="Email" />
{errors.email && <p role="alert">{errors.email}</p>}
<input name="password" type="password" value={values.password} onChange={handleChange} placeholder="Password" />
{errors.password && <p role="alert">{errors.password}</p>}
<input name="confirmPassword" type="password" value={values.confirmPassword} onChange={handleChange} placeholder="Confirm password" />
{errors.confirmPassword && <p role="alert">{errors.confirmPassword}</p>}
<button type="submit">Sign up</button>
</form>
);
}
export default SignupWithHook;
This renders three password-style inputs plus a submit button. Submitting with a mismatched confirmation, a short password, or a malformed email shows the relevant message under each field; submitting valid values logs Output: Account created for: alice@example.com and shows no errors at all. The hook returns exactly the pieces a form needs (values, errors, and the two handlers) while hiding the state management inside — any component can now get full validation behavior in four lines.
How It Works Step by Step
- On mount —
valuesis initialized (usually to empty strings) anderrorsstarts as{}, so no error messages render yet. - On keystroke —
onChangefires, computes the next values object immutably, and callssetValues. React schedules a re-render; the input’svaluenow reflects what was typed. If the field is already flagged invalid (or you always re-validate on change),setErrorsis also called with a freshly computed errors object. - On blur —
onBlurmarks the field astouchedand runsvalidateagainst the current values, populatingerrorsfor the first time for that field. Becausetouchedgates whether the message renders, fields the user hasn’t reached yet stay silent. - On submit —
onSubmitcallse.preventDefault()to stop the browser’s native page reload, runsvalidateagainst every field, marks everything as touched (so every remaining error becomes visible), and only proceeds with the “real” submit logic (an API call, aconsole.log, a navigation) if the resulting errors object is empty. - On re-render — React compares the new JSX (with the updated
errors) to the previous render during reconciliation, and only patches the DOM nodes that actually changed — typically just the text of one error paragraph, or its insertion/removal — not the whole form.
Common Mistakes
1. Mutating the errors object instead of replacing it
State must be treated as immutable. Mutating it directly can silently fail to trigger a re-render, because React uses reference equality to decide whether state changed.
function handleBlur(e) {
errors[e.target.name] = "Required";
setErrors(errors);
}
Here, errors is mutated in place and then passed back to setErrors as the very same reference it already had — React may skip the re-render entirely, so the error never appears. Build a new object instead:
function handleBlur(e) {
setErrors((prev) => ({ ...prev, [e.target.name]: "Required" }));
}
2. Forgetting e.preventDefault() in the submit handler
Without it, the browser performs its default full-page form submission (a GET/POST navigation) the instant the user clicks submit — wiping out your component’s state, including any errors you were about to compute, before React even gets to render them.
function handleSubmit(e) {
const validationErrors = validate(values);
setErrors(validationErrors);
}
The page reloads before the user ever sees the error. Always stop the native behavior first:
function handleSubmit(e) {
e.preventDefault();
const validationErrors = validate(values);
setErrors(validationErrors);
}
3. A stale closure in a debounced validation effect
When you debounce validation with useEffect and setTimeout, an empty dependency array means the effect’s callback captures the values that existed on the very first render — forever.
useEffect(() => {
const timer = setTimeout(() => {
setErrors(validate(values));
}, 500);
return () => clearTimeout(timer);
}, []);
Every keystroke after the first render is validated against stale, outdated values from mount, because the effect never re-runs. Include every reactive value the effect reads:
useEffect(() => {
const timer = setTimeout(() => {
setErrors(validate(values));
}, 500);
return () => clearTimeout(timer);
}, [values]);
Best Practices
- Keep
validate(values)as a pure function with no side effects — it makes the logic easy to unit test in isolation, outside of React entirely. - Use a
touchedstate (or validate only on blur/submit) so users aren’t shown errors for fields they haven’t reached yet. - Always call
e.preventDefault()in your submit handler, and addnoValidateto the<form>if you’re fully replacing the browser’s built-in validation UI with your own. - Give every error message a
role="alert"(or associate it with the input viaaria-describedby) so assistive technology announces it. - Compute the “next” value explicitly (e.g.
{ ...values, [name]: value }) rather than reading state right after setting it — state updates aren’t applied until the next render. - Extract shared validation logic into a custom hook (like
useFormValidation) once you’re duplicating the same pattern across two or more forms. - For anything beyond a handful of fields or asynchronous rules (like checking username availability against a server), consider a dedicated library such as React Hook Form or Formik — the hand-rolled pattern here is the foundation those libraries build on.
Practice Exercises
- Build a single-field form that validates a phone number (must be exactly 10 digits) on blur, showing “Phone number must be 10 digits.” when invalid.
- Extend the two-field
SignupFormexample with a third field,confirmPassword, that must matchpassword; make sure the error clears immediately once the two fields match, without waiting for another blur. - Modify the
useFormValidationhook so it also returns ahandleBlurfunction and atouchedobject, so errors only display for fields the user has actually visited, matching the behavior of Example 2.
Summary
- React form validation is built from ordinary state: a
valuesobject, anerrorsobject, and a purevalidate(values)function — there’s no special validation API. - Decide when to validate: on submit (simplest), on blur (earlier feedback), or on change for fields already flagged invalid (so errors clear as the user fixes them).
- Always compute the next values/errors explicitly rather than reading state you just set — state updates apply on the next render, not immediately.
- Never mutate
errorsorvaluesin place; always create a new object so React detects the change and re-renders. - Always call
e.preventDefault()in the submit handler so the browser doesn’t reload the page before your validation runs. - Extract repeated validation logic into a custom hook like
useFormValidationto keep individual form components small and consistent.
