Validate a controlled React form on submit
Canonical URL: https://devexamples.com/javascript/validate-a-controlled-react-form/
The problem
A controlled form that validates on every keystroke reports errors before the reader has finished entering anything, while a form that validates only in the submit handler can silently drop a field the rule set never mentions.
Short answer
Store touched state per field, run rules once on submit, render the returned message array by field name, and let the browser handle required and type checking unless you replace it.
Controlled values make the rules a pure function of state, which is the advantage worth taking.
import { useState } from 'react';
const rules = {
name: (values) => (values.name.trim().length < 2 ? 'Enter your full name.' : ''),
email: (values) =>
/^[^@\s]+@[^@\s.]+\.[^@\s]{2,}$/.test(values.email) ? '' : 'Enter a valid email address.',
confirm: (values) => (values.confirm === values.email ? '' : 'The two addresses must match.'),
};
const initialValues = { name: '', email: '', confirm: '' };
export function SignUpForm({ onSubmit }) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
function handleChange(event) {
const { name, value } = event.target;
setValues((current) => ({ ...current, [name]: value }));
// Clear a shown error as soon as the field changes; do not add new ones yet.
if (touched[name]) {
setErrors((current) => ({ ...current, [name]: rules[name](values) }));
}
}
function handleBlur(event) {
const { name } = event.target;
setTouched((current) => ({ ...current, [name]: true }));
setErrors((current) => ({ ...current, [name]: rules[name](values) }));
}
function handleSubmit(event) {
event.preventDefault();
const next = {};
for (const [field, rule] of Object.entries(rules)) {
const message = rule(values);
if (message) next[field] = message;
}
setErrors(next);
setTouched(Object.fromEntries(Object.keys(rules).map((field) => [field, true])));
if (Object.keys(next).length === 0) onSubmit(values);
}
return (
<form onSubmit={handleSubmit} noValidate>
{Object.keys(initialValues).map((field) => (
<p key={field}>
<label htmlFor={field}>{field}</label>
<input
id={field}
name={field}
value={values[field]}
onChange={handleChange}
onBlur={handleBlur}
aria-invalid={Boolean(errors[field]) || undefined}
aria-describedby={errors[field] ? `${field}-error` : undefined}
/>
{errors[field] ? <span id={`${field}-error`}>{errors[field]}</span> : null}
</p>
))}
<button type="submit">Create account</button>
</form>
);
}Explanation
The rule table is the important part: iterating Object.entries(rules) on submit means a new
field without a rule is a visible omission in one place rather than a silent hole. The common
version of this bug is a handler that checks each field by hand and forgets the one added last
month. Deriving the error object from the same table the fields are rendered from keeps the two
lists in step.
Validation timing is a state question rather than a rule question. Errors appear on submit or on blur after a field is touched, and never while a field is untouched, so an empty email input does not shout at the reader on the first character. Clearing an error as the value changes is a different action from adding one, and only the clearing is safe mid-typing.
Because the inputs are controlled with noValidate, this component owns the whole message path.
That is a deliberate trade: it buys a consistent message style and cross-field rules such as the
confirmation comparison, which native constraints cannot express, and it costs the free
localisation, focus management, and constraint bubbles the platform provides. If you keep native
validation, drop noValidate and read checkValidity() instead of duplicating the rules.
The aria-describedby attribute is applied only when a message exists. Pointing at an id that is
not rendered leaves the reference dangling, and some combinations of browser and screen reader
announce nothing rather than reporting the broken reference, which is hard to notice in testing.
Assumptions
- React 19 with a function component;
onSubmitis the caller’s side effect, kept out of the validation logic. - Field names are known at module scope; the
rulesandinitialValuesobjects must list the same fields.
Parameters
onSubmit(function, required): called with the validatedvaluesobject only when no rule produced a message.
Common mistakes
- Adding an error on every keystroke, so the reader sees “Enter a valid email address.” while typing the first letter.
- Reading
valuesfrom the render closure insidehandleChange, which reports against the previous value; shown here as an accepted trade-off for the blur path and worth closing with the functional update if you validate against the new value.
Caveats
- Cross-field rules make clearing one field stale another’s message; the submit pass recomputes everything, the incremental path does not.
- Hiding the message node when there is no error means the live-region announcement depends on insertion order; use a persistent described-by target when you need an announcement.
Alternatives
Validating on change gives immediate feedback but punishes the first keystroke of an email address; validating only on submit is calmer and matches native behaviour, at the cost of a later discovery. The middle path, validating a field only after it has been touched and left, needs the extra state shown here.
Prerequisite
Prerequisite
Validate form input before the browser submits itThe plain submit-time rules carry into the component unchanged.
Examples that treat this as a prerequisite
Derived at build time from the editorial graph; no edge is invented here.
- Type the validation state of a controlled form
The field rules are the same in the untyped version, so read that before adding the types.
Related examples
Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.
Related
Type the validation state of a controlled formThe typed version of the same field-error state.
Same task or topic
Announce field errors without moving the caretSolves the Validate form input task
Same task or topic
Validate form input before the browser submits itSolves the Validate form input task
References
- Input component(opens in a new tab) — React Team. Documents controlled input behaviour and value handling in React.
- aria-describedby(opens in a new tab) — MDN. Explains linking a rendered message to the control it describes.
Source page: https://devexamples.com/javascript/validate-a-controlled-react-form/