Forms & validation
Start with a form the browser can submit
A native form already has a submission path. The action identifies the URL that processes it, and method="post" puts the form data in the request body. With the default get method, the browser appends the data to the action URL. Controls need name values because form data is built from name and value entries.
<form action="/accounts" method="post">
<label for="email">Email</label>
<input
id="email"
name="email"
type="email"
autocomplete="email"
required
>
<button type="submit">Create account</button>
</form>
The explicit label gives the input a usable name. type="email" adds the browser's email syntax constraint, and required makes an empty value invalid. Activating the submit button starts interactive constraint validation. If a control fails, the browser prevents submission and reports a validation problem.
Wrong "A placeholder can replace the label because both display the field's purpose."
Right Use a <label> for the control. A placeholder is a hint inside a text control and does not replace the labeling relationship.
Native constraints cover common rules
HTML can express many local input rules without JavaScript. The available attributes depend on the control and input type.
<label for="seats">Seats</label>
<input id="seats" name="seats" type="number" min="1" max="8" required>
<label for="code">Invite code</label>
<input
id="code"
name="code"
type="text"
pattern="[A-Z]{4}"
minlength="4"
maxlength="4"
aria-describedby="code-format"
>
<p id="code-format">Use four uppercase letters.</p>
The corresponding validity states include valueMissing, rangeUnderflow, rangeOverflow, patternMismatch, tooShort, and tooLong. A control that fails a constraint matches :invalid; one that satisfies its constraints matches :valid.
Do not make every button an accidental submitter. In an ordinary form, a <button> with no type defaults to submit. Give non-submission actions type="button".
<button type="button" id="show-password">Show password</button>
<button type="submit">Sign in</button>
The server still validates
Client validation gives fast feedback, but a caller can change the HTML or send an HTTP request without using the page. The HTML standard describes constraint validation as a user-experience feature, not a security mechanism.
Wrong "The browser blocked the invalid form, so malformed data cannot reach the server."
Right Apply compatible rules on the client and validate every submitted value on the server. Only the server can enforce the application's trust boundary.
Disabled controls add another practical trap. They do not participate in constraint validation and are not submitted with the form. A named text input with readonly remains focusable and is submitted, although it is also excluded from constraint validation. The readonly attribute does not apply to every type of form control, so it is not a general replacement for disabled.
Read the validity state before inventing another validator
The Constraint Validation API exposes the browser's result on form controls. validity is a ValidityState object, validationMessage is the localized message the browser would report, and willValidate says whether the control is a validation candidate.
const email = document.querySelector("#email");
if (email.validity.valueMissing) {
console.log("The required value is missing");
} else if (email.validity.typeMismatch) {
console.log("The value does not match the email syntax constraint");
}
checkValidity() returns a boolean and fires invalid on each invalid control. reportValidity() performs the check and asks the browser to report the problems to the user. Interactive submission also validates unless the form has novalidate or the activated submitter has formnovalidate.
const form = document.querySelector("#signup");
// Returns the result and lets the browser report invalid controls.
const valid = form.reportValidity();
Do not call both methods for the same check. Each method runs validation and fires invalid on controls that fail, so checkValidity() followed by reportValidity() repeats those events.
Custom errors have persistent state
Cross-field rules do not fit an attribute such as required or max. setCustomValidity() lets code attach a custom error to a control. Any non-empty message makes that control invalid. Code must clear the message when the rule passes.
const password = document.querySelector("#password");
const confirmation = document.querySelector("#password-confirmation");
function validateConfirmation() {
if (confirmation.value !== password.value) {
confirmation.setCustomValidity("Passwords must match.");
} else {
confirmation.setCustomValidity("");
}
}
password.addEventListener("input", validateConfirmation);
confirmation.addEventListener("input", validateConfirmation);
Wrong "Calling checkValidity() again clears an old custom error once the values match."
Right The message is state. Clear it explicitly with setCustomValidity(""), or customError continues to fail validation.
An accessible error needs state and an explanation
aria-invalid="true" identifies a control that has failed validation. WAI guidance says not to set it to true before validation has happened. It does not supply the error text. aria-describedby connects the control to one or more descriptive elements by their IDs.
<label for="email">Email</label>
<input
id="email"
name="email"
type="email"
required
aria-invalid="true"
aria-describedby="email-error"
>
<p id="email-error">Enter an email address in the form name@example.com.</p>
The error must also be visible text. A red border alone cannot describe what went wrong, and aria-invalid alone only exposes the state. When the user corrects the value, remove or set aria-invalid to false, remove stale error text, and clear a custom validity message if one was set. If aria-describedby also names help text, remove only the error ID.
function clearEmailError() {
email.removeAttribute("aria-invalid");
const descriptions = (email.getAttribute("aria-describedby") ?? "")
.split(/\s+/)
.filter((id) => id && id !== "email-error");
if (descriptions.length > 0) {
email.setAttribute("aria-describedby", descriptions.join(" "));
} else {
email.removeAttribute("aria-describedby");
}
document.querySelector("#email-error")?.remove();
email.setCustomValidity("");
}
For a failed submission with several fields, WAI recommends a clear error list before the form. Each entry can link to its control. For dynamically inserted feedback, its guidance uses a prominent container with role="alert" to notify assistive technology. After field errors are rendered and associated, moving focus to the first invalid input can give keyboard users a concrete recovery point.
Wrong "Focus the submit button again so the user can retry quickly."
Right Put focus on the first invalid control after its error has been rendered. The user then encounters the field name, invalid state, and associated description at the place that needs correction.
Submission has more than one code path
Normal user submission can come from activating a submit button or pressing Enter while editing a form field. The submit event fires on the form, and its SubmitEvent.submitter identifies the button that triggered the request. That matters when buttons override the form through attributes such as formaction, formmethod, or formnovalidate.
const form = document.querySelector("#profile");
form.addEventListener("submit", (event) => {
event.preventDefault();
const submitter = event.submitter;
const action = submitter?.hasAttribute("formaction")
? submitter.formAction
: form.action;
const method = submitter?.hasAttribute("formmethod")
? submitter.formMethod
: form.method;
console.log(action, method);
});
An invalid interactive submission fires invalid on failing controls and prevents the submit event. This ordering is why a submit listener is not a complete place to observe failed native validation.
The scripted methods have different semantics. requestSubmit() acts like a submit-button activation: it follows interactive constraint validation, including novalidate and formnovalidate, honors an optional submitter, and reaches submit when the submission algorithm continues. submit() sends the form directly. It does not run constraint validation and does not fire submit.
// Preserve validation, submitter overrides, and submit-event behavior.
form.requestSubmit(form.querySelector("button[type='submit']"));
Wrong "Call form.submit() inside a helper because it is equivalent to clicking the submit button."
Right Use requestSubmit() when code needs the native submission algorithm. Reserve submit() for the unusual case where bypassing both validation and the submit event is intended.
Progressive enhancement keeps the request path intact
The baseline form should remain useful before JavaScript runs. It needs a real action, an appropriate method, named controls, labels, and constraints that HTML can express.
<form id="profile" action="/profile" method="post">
<label for="display-name">Display name</label>
<input
id="display-name"
name="displayName"
required
minlength="2"
maxlength="60"
>
<button type="submit">Save profile</button>
</form>
JavaScript can enhance the same form with in-page submission and richer feedback. The interception belongs on the form's submit event so keyboard submission and submit-button activation share the path.
form.addEventListener("submit", async (event) => {
const submitter = event.submitter;
const method = (submitter?.hasAttribute("formmethod")
? submitter.formMethod
: form.method
).toLowerCase();
// This enhancement handles POST. Leave other methods to the browser.
if (method !== "post") return;
event.preventDefault();
const action = submitter?.hasAttribute("formaction")
? submitter.formAction
: form.action;
const body = submitter
? new FormData(form, submitter)
: new FormData(form);
const response = await fetch(action, {
method,
body,
});
if (!response.ok) {
const result = await response.json();
renderServerErrors(result.errors);
}
});
Passing the submitter to FormData includes its name and value. Checking its override attributes also respects formaction and formmethod. The formAction property alone is not a fallback to form.action: without a formaction attribute, its getter resolves the empty attribute against the document URL. A broader interceptor must either reproduce other native semantics, including formenctype and formtarget, or leave those submissions to the browser.
This enhancement does not promote the client to the authority. The server validates every request, including requests that never came from this page. On failure, it can return field-specific text and the submitted values. The enhanced path renders those errors into the current document; the native path returns a page containing equivalent feedback.
Error recovery is one contract across both paths
A strong error response does four jobs: it says that submission failed, identifies each field, describes the correction in text, and provides a route back to the field. WAI's form guidance supports a summary before the form with links to controls, plus inline messages associated through aria-describedby.
<section id="error-summary" role="alert">
<h2>There are 2 errors</h2>
<ul>
<li><a href="#email">Email: enter a valid address.</a></li>
<li><a href="#country">Country: choose an option.</a></li>
</ul>
</section>
For an in-page update, WAI identifies focusing the first invalid input as convenient after errors. Make the move after the new content and ARIA relationships exist, and keep the normal focus indicator visible.
Wrong "An assertive live region solves error handling, so focus and field associations are redundant."
Right A live region announces a dynamic update. Keep visible error text and a route back through links or focus placement. In this pattern, aria-invalid exposes the failed state and aria-describedby associates each field-specific explanation.
Native validation messages vary by browser and locale, and their presentation is not author-styled in a standard way. A custom error UI buys control at a cost: the application now owns message lifecycle, programmatic relationships, announcement behavior, and focus recovery. Keeping the native constraints underneath still provides a shared validity model.
dig deeper
Primary sources behind these notes - the specs and official docs worth reading in full.
- WHATWG: Constraint validation ↗html.spec.whatwg.org
- MDN: Constraint Validation API ↗developer.mozilla.org
- MDN: HTMLFormElement.requestSubmit() ↗developer.mozilla.org
- MDN: FormData constructor ↗developer.mozilla.org
- WAI: Form user notifications ↗w3.org
- WAI: Using aria-invalid to indicate an error field ↗w3.org
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.