Reading the values
- Only controls with a
nameare included. A field with no name is invisible here and to the server. - An unchecked checkbox is absent, not
false.fd.has("consent")is the test. Object.fromEntrieskeeps only the last value of a repeated name — usegetAllfor those.- Every value is a string, except a file input, which gives you a
File.
const form = document.querySelector("#signup");
const data = Object.fromEntries(new FormData(form));
// { name: "Ulvi", email: "a@b.co", plan: "pro" }
// a field that can appear several times
const fd = new FormData(form);
fd.getAll("interests"); // ["html", "css"]
fd.get("email");
fd.has("consent"); // false when a checkbox is unchecked
Reaching a control
form.elements.email; // by name or id — the clearest
form.email; // the same thing, shorter
document.querySelector("#email");
form.elements.email.value;
form.elements.consent.checked;
form.elements.plan.value; // for a radio group: the checked one
Submitting without a reload
form.addEventListener("submit", async (e) => {
e.preventDefault();
const button = form.querySelector("[type=submit]");
button.disabled = true; // stop a double submit
try {
const res = await fetch(form.action, {
method: form.method,
body: new FormData(form), // sends as multipart
});
if (!res.ok) throw new Error(`${res.status}`);
form.reset();
show("Sent.");
} catch (err) {
show("Could not send. Try again.");
} finally {
button.disabled = false; // even if it threw
}
});
Send JSON instead when the API wants it
body: JSON.stringify(Object.fromEntries(new FormData(form))) with a Content-Type: application/json header.
Using the browser's validation
form.checkValidity(); // true/false, silently
form.reportValidity(); // the same, and shows the native bubbles
field.validity.valid;
field.validity.valueMissing; // required and empty
field.validity.typeMismatch; // not an email/url
field.validity.patternMismatch;
field.validity.tooShort;
field.validationMessage; // the browser's own wording, localised
Your own messages
role="alert"makes the message announced when it appears.aria-describedbyties the message to the field, so a screen reader reads it when the field is focused.aria-invalidtells assistive technology the field is in an error state.- Focus the first bad field:
email.focus(). Otherwise a keyboard user has to hunt for it.
<form id="signup" novalidate>
<label for="email">Email</label>
<input type="email" id="email" name="email" required aria-describedby="email-err">
<p id="email-err" class="error" role="alert"></p>
</form>
function showError(field, message) {
const out = document.getElementById(`${field.id}-err`);
out.textContent = message ?? "";
field.setAttribute("aria-invalid", message ? "true" : "false");
}
form.addEventListener("submit", (e) => {
e.preventDefault();
const email = form.elements.email;
if (email.validity.valueMissing) return showError(email, "Please enter your email.");
if (email.validity.typeMismatch) return showError(email, "That does not look like an email address.");
showError(email, null);
send();
});
Custom rules
function compare() {
again.setCustomValidity(
again.value === pass.value ? "" : "The two passwords do not match."
);
}
pass.addEventListener("input", compare);
again.addEventListener("input", compare);
Clear it with an empty string
Any non-empty message marks the field invalid forever. The "" branch is not optional.
When to validate
- On submit — always. This is the one that matters.
- On blur — after the reader has left the field, so they are not corrected mid-word.
- On input — only to clear an error that is already showing, never to add one.
- In CSS, style
:user-invalid, not:invalid, so an untouched empty form is not red.
And on the server, again
Everything above is a convenience for the person filling the form. None of it is a defence: a request can be sent with curl, from the console, or by a script that never loaded your page.
Every rule enforced in the browser must be enforced again on the server. Treat the client version as a nicety and the server version as the truth.