Validate form input before the browser submits it
Canonical URL: https://devexamples.com/javascript/validate-form-input-before-submit/
The problem
A signup form has rules the markup cannot state: two email fields must match, and a team size has to fall inside a range that depends on the plan. The reader should not discover a mismatch after a round trip, and fixing it must not cost the free validation the browser already performs on required fields and typed inputs. Assumes a plain form element, real labels, and a server that will check all of this again.
Short answer
Listen for submit, re-run the browser's own verdict with form.checkValidity(), express each extra rule as a control's custom validity message, stop the submission only when a rule fails, and hand the reporting to form.reportValidity() so the message appears on the field it belongs to.
A browser already validates what it can express in markup: required, type, min, max, pattern. The gap is everything that needs two values compared, or a decision the attributes cannot state. Filling that gap without switching the built-in behaviour off is the whole trick, and it happens in the submit event, which is fired only after the browser’s own checks have already passed.
<form id="signup" method="post" action="/account/create">
<p>
<label for="signup-email">Email address</label>
<input id="signup-email" name="email" type="email" required autocomplete="email">
</p>
<p>
<label for="signup-email-confirm">Confirm email address</label>
<input id="signup-email-confirm" name="emailConfirm" type="email" required autocomplete="email">
</p>
<p>
<label for="signup-team">Seats you need</label>
<input id="signup-team" name="team" type="number" min="1" max="500" required>
</p>
<button type="submit">Create account</button>
</form>const signupForm = document.querySelector('#signup');
// Rules the attributes cannot express, because each one reads two controls.
const crossFieldRules = [
{
field: 'emailConfirm',
message: 'Enter the same email address twice.',
isValid: (field, form) => field.value === form.elements.email.value
}
];
function markCrossFieldRules(form) {
let firstInvalid = null;
for (const rule of crossFieldRules) {
const field = form.elements[rule.field];
if (!field) {
continue;
}
const ok = rule.isValid(field, form);
// An empty message is what makes a control valid again.
field.setCustomValidity(ok ? '' : rule.message);
if (!ok && firstInvalid === null) {
firstInvalid = field;
}
}
return firstInvalid;
}
signupForm.addEventListener('submit', (event) => {
// Attribute failures normally stop the submission before this event exists,
// so this branch is reached only with novalidate on the form or a scripted
// requestSubmit(). Running it anyway keeps both paths in the same order.
if (!signupForm.checkValidity()) {
event.preventDefault();
signupForm.reportValidity();
return;
}
if (markCrossFieldRules(signupForm) !== null) {
event.preventDefault();
signupForm.reportValidity();
return;
}
// Deliberately no preventDefault here: method and action do the rest.
});
// Any edit makes the previous verdict stale, including an edit to the field the
// rule compares against, so clear the messages rather than leaving a stuck one.
signupForm.addEventListener('input', () => {
for (const rule of crossFieldRules) {
const field = signupForm.elements[rule.field];
if (field) {
field.setCustomValidity('');
}
}
});Explanation
Two properties of the submit event make this shape possible. First, the browser runs constraint validation as part of form submission and fires the event only when that pass is clean, so a required field left empty never reaches this handler at all — it gets the platform’s own message instead. That is the reason not to duplicate attribute rules in script: the handler is already running after them, and a second copy of the same logic is one more thing to keep in sync. Second, preventDefault() is the only thing that stops the submission here, so a handler that calls it unconditionally turns a working form into a dead button. The code therefore prevents the default in exactly two branches, and falls off the end when everything passes.
setCustomValidity() is what lets a script-written rule join the browser’s own system rather than compete with it. Any non-empty message marks that control as failing constraint validation, which means the message becomes available to the same machinery that shows the platform’s text: form.checkValidity() now includes it in its verdict, and form.reportValidity() focuses the first failing control and shows its message. That is why the handler does not build an error list of its own, and why it does not need to: one call reports whatever the reader needs to see, in the browser’s established style, attached to the field that carries the label. The cost of borrowing that machinery is the state it leaves behind — the control stays invalid until the message is set back to an empty string, so the input listener is not politeness, it is what prevents a permanently blocked form.
The boundary conditions are the two configurations where the first branch stops being defensive. With novalidate on the form, or formnovalidate on the button, the browser dispatches submit whatever the state of the fields, so checkValidity() is the only thing standing between a typo and a server round trip; keep that call and it costs nothing in the normal case, because validation has already run and returns true. The other configuration is scripted submission: form.submit() neither fires the event nor validates anything, so a page that submits that way silently skips every rule in this file, while form.requestSubmit() behaves like the button and runs the whole sequence. Errors surfaced this way are visual and caret-moving by design, and the messages are generated per engine in the reader’s language — two reasons the browser’s own text is worth keeping for the attribute rules and only extending where the markup cannot speak.
Next steps
The rules here live in one handler and one array. Growing them means naming the fields and their failures as data, which is where the typed form-state examples start, and the immediate step after this one is making the same failures legible to a reader who never sees the bubble.
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
field | string control name | Yes | — | Name of the control the rule can invalidate, looked up through the form's elements collection. |
message | string | Yes | — | The text the reader sees for this failure; it is written for one field, not for the form as a whole. |
isValid | function taking the control and the form | Yes | — | Returns true when the value is acceptable, which is what decides whether the message is set or cleared. |
Expected output
A mismatched pair never reaches the server: the second field keeps the browser's message naming the rule, the reader corrects it, and a form that satisfies every rule submits to its action exactly as the markup says.
Usage notes
- Keep everything the attributes can express in the markup, and let the handler carry only rules that need two values at once; that split is what keeps it short.
- Trigger a scripted submission with form.requestSubmit(), never form.submit(), because submit() fires no submit event and skips this handler and every validity check with it.
Common mistakes
- Adding novalidate to the form and never calling checkValidity(), which throws away the validation the browser was doing for free.
- Calling preventDefault() on every attempt, so a form that is perfectly valid never submits and the reader has no way to learn why.
- Setting the custom message on failure but never clearing it on input, after which the field cannot become valid again.
- Duplicating each attribute rule in script, so required and type checks exist twice and drift apart on the next edit.
Caveats
- Nothing here is a security control: values that arrive at the server still have to be checked there, because this handler is trivially absent.
- A custom message leaves its control invalid, so once one is set the browser withholds later submissions until something clears it.
- reportValidity() shows a bubble and moves focus, which is a poor experience for a reader who cannot see either; announcing the same failure without moving the caret is a separate layer.
Alternatives
Plain submit-time validation costs no framework and inherits the browser's bubbles, focus handling and required and type rules for free, but the rules live in imperative DOM code and the failure text is not something a component can render where it wants. Controlled React state inverts that: the same comparison becomes a value the interface can display beside any field and re-check on every change, paid for with a state entry per field, a re-render per keystroke, and no native bubble unless the message is rebuilt in markup. For one form with two cross-field rules the plain handler is the shorter contract; the React version earns its place when error output drives other rendered state, such as a live summary or a disabled submit button.
Prerequisite
Prerequisite
Add an event listener with the options you actually needThis handler is an event listener, so the options that decide when it runs and how it is removed come first.
Follow-up
Follow-up
Announce field errors without moving the caretOnce the rules produce a list of failures, announcing them without moving the caret is the next layer.
Examples that treat this as a prerequisite
Derived at build time from the editorial graph; no edge is invented here.
- Validate a controlled React form on submit
The plain submit-time rules carry into the component unchanged.
Related examples
Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.
Related
Associate every form control with an explicit labelA message set on a control with no label association reaches a reader as an unnamed failure.
Alternative approach
Validate a controlled React form on submitThe same submit-time rules written against React state that every field already controls.
Same task or topic
Announce field errors without moving the caretSolves the Validate form input task
References
- HTMLFormElement: checkValidity() method(opens in a new tab) — MDN Web Docs. What the method returns and which constraint rules it consults without showing a message.
- Constraint validation - HTML Living Standard(opens in a new tab) — WHATWG. The processing model that withholds the submit event when a control fails.
- HTMLInputElement: setCustomValidity() method(opens in a new tab) — MDN Web Docs. How a scripted message becomes part of a control's validity state.
Source page: https://devexamples.com/javascript/validate-form-input-before-submit/