React form validation without a library means checking user input with plain state and plain functions before you accept a submission. The goal is a visible, accessible error for every invalid field, plus a clear boundary between quick client checks and authoritative server validation. You build it with controlled inputs, an errors object, and one validate function.
Validate on submit with a plain function
The validate function takes the current values and returns an object whose keys match the field names. A missing key means the field is valid, and a key with a message means the field failed.
function validate(values) {
const errors = {};
if (!values.name.trim()) {
errors.name = "Enter your name.";
}
if (!values.email.includes("@")) {
errors.email = "Enter a valid email address.";
}
return errors;
}Empty strings are falsy, so the name check catches a blank field, and the email check catches a missing @ sign. Returning an empty object tells the caller that every field passed.
Keep values and errors in state
The form holds two separate pieces of state: the values object and the errors object. The submit handler runs validate and stores the result, while the change handler updates one field at a time.
import { useState } from "react";
export default function SignupForm() {
const [values, setValues] = useState({ name: "", email: "" });
const [errors, setErrors] = useState({});
function handleSubmit(e) {
e.preventDefault();
setErrors(validate(values));
}
function handleChange(e) {
setValues({ ...values, [e.target.name]: e.target.value });
}
// the return below continues this same componentThe change handler copies the previous object and replaces only the edited key, so the update stays immutable. The submit handler stops the page reload first, then fills the errors state with whatever validate found.
Show errors next to each field
Render each message beside its input and connect the two with ARIA so assistive technology announces the failure. This return block still belongs inside the SignupForm component above.
return (
<form onSubmit={handleSubmit} noValidate>
<label htmlFor="name">Name</label>
<input id="name" name="name" value={values.name} aria-invalid={errors.name ? "true" : "false"} aria-describedby="name-error" onChange={handleChange} />
{errors.name && <p id="name-error" role="alert">{errors.name}</p>}
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" value={values.email} aria-invalid={errors.email ? "true" : "false"} aria-describedby="email-error" onChange={handleChange} />
{errors.email && <p id="email-error" role="alert">{errors.email}</p>}
<button type="submit">Sign up</button>
</form>
);
}The noValidate prop turns off browser tooltips so your own messages stay consistent. aria-invalid flags the failed field, and aria-describedby points the message at it, so a screen reader announces the error when the field regains focus.
Validate on blur for failed fields
Validating only on submit is correct, but users also appreciate a recheck once they have already failed a field. Validate again when a field loses focus, and only if it already has an error.
function handleBlur(e) {
if (errors[e.target.name]) {
setErrors(validate(values));
}
}Attach handleBlur to each input. The guard means a clean field is not interrupted while the user is still typing, but a field that already failed is rechecked the moment the user leaves it, so the message clears as soon as it is fixed.
Check format, not just presence
A blank field is the easiest mistake to catch, but presence is rarely enough on its own. An email needs an @ sign, a password needs a minimum length, and a number must fall inside a range. Keep these checks in the same validate function and return one clear message per field.
When the rules grow large or need to be shared with the server, move them into a schema instead of hand-writing dozens of branches. The error object stays the same either way, so the UI does not care how a rule was checked. Start with presence, then layer in the format rules your data actually needs.
Separate client checks from server validation
The checks above are convenience, not security. Anyone can bypass client-side JavaScript, so the server must validate the same data again and decide whether the request is accepted.
- Client validation gives fast, friendly feedback before the request is sent.
- Server validation is authoritative and returns the real errors after the request.
- Show server errors in the same errors object so the UI stays consistent.
The same errors state can hold messages the server returns, so one component renders both kinds of feedback. For the schema approach that replaces the hand-written validate function, see how to validate React forms with Zod.
For displaying the errors a server sends back, see how to show server validation errors in React forms. The basic form wiring is covered in how to build forms in React.
Rune AI
Key Insights
- Keep values and errors in separate state.
- Return an errors object from one validate function.
- Show errors with role=alert and aria-describedby.
- Validate on submit, then recheck failed fields on blur.
- Client validation never replaces server validation.
Frequently Asked Questions
Do I need a validation library for React forms?
Should I validate on submit or on every keystroke?
Is client-side validation enough?
Conclusion
Validate React forms with a plain function and an errors object. Show each message accessibly, and treat client checks as convenience while the server stays the authority.
More in this topic
How to Build a Dropdown Menu in React
Build a React dropdown menu with the ARIA menu button pattern. Handle open and close, keyboard arrows, and clicks outside the menu.
How to Animate React Components with Motion
Animate React components with the Motion library. Set up motion, add enter, hover, and exit animations, and respect reduced motion.
Headless UI Components Explained: Logic Without Locked Styling
Understand headless UI components and how libraries like Radix give you unstyled, accessible behavior that you style yourself.