Forms and JavaScript
Read values, submit without a page reload, validate with the built in API, and handle errors accessibly. All of it built on a real form element.
-
HTML Basics
- What is HTML: The Structure Layer of Every Web Page
- HTML Document Structure: DOCTYPE, html, head and body
- Elements, Tags and Attributes: The Vocabulary of HTML
- HTML Comments: Notes That Ship With Your Code
- Block Level and Inline Elements
- Writing and Running Your First HTML Page
- How a Browser Turns Markup Into a Page
- Text and Formatting
- Links and Navigation
- Images and Media
- Lists
- Tables
-
Forms
- Form Structure: form, action and method
- Input Types: Text, Email, Number, Date and the Rest
- Labels: The Most Important Element in a Form
- Checkboxes, Radio Buttons and Grouping
- select, option, optgroup and datalist
- textarea, File Uploads and Hidden Fields
- Buttons: submit, reset and button
- Built In Form Validation
- GET or POST: What Happens When a Form Is Submitted
- Semantic HTML
- HTML5 Features
- Head and Metadata
- HTML with CSS
- HTML with JavaScript
- Accessibility
-
HTML SEO
- How Google Works: Crawling, Indexing and Ranking
- SEO Friendly HTML Structure
- Titles and Descriptions That Earn Clicks
- Headings and Content Structure for Search
- Internal Linking and Anchor Text
- robots.txt and XML Sitemaps
- Canonical URLs and Duplicate Content
- Structured Data and JSON-LD
- Image SEO
- Core Web Vitals and Mobile Friendliness
- DevTools and Debugging
- Editor Productivity
- HTML Best Practices
- HTML Projects
- Advanced Projects
- Practice and Exams
Concept
Even a form handled entirely in JavaScript should be a real form element. You inherit keyboard submission, browser autofill, native validation, correct semantics for assistive technology and a working experience if the script fails - none of which is worth reimplementing.
Reading values
const form = document.getElementById("enquiry");
// one field
form.elements.email.value;
form.querySelector("[name='email']").value;
// everything at once
const data = new FormData(form);
data.get("email");
data.getAll("topics"); // several values under one name
Object.fromEntries(data); // a plain object, losing repeated keysFormData collects exactly what a real submission would: every named, enabled control, with checkboxes and radios included only when checked. It is the reliable way to read a form, because it applies the same rules the browser does.
Submitting without a page reload
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("[type='submit']");
button.disabled = true;
status.textContent = "Sending...";
try {
const response = await fetch(form.action, {
method: form.method,
body: new FormData(form),
headers: { "Accept": "application/json" },
});
if (!response.ok) throw new Error(`Server returned ${response.status}`);
const result = await response.json();
status.textContent = result.message || "Thank you, we will be in touch.";
form.reset();
} catch (error) {
status.textContent = "Could not send the form. Please try again.";
} finally {
button.disabled = false;
}
});<form id="enquiry" action="/api/enquiry" method="post">
...
<button type="submit">Send enquiry</button>
</form>
<p id="status" role="status" aria-live="polite"></p>Three details make this good rather than merely working. The action and method are read from the form, so the markup stays the source of truth and the form still works without JavaScript. The submit button is disabled during the request, preventing double submission. And the status message sits in a live region, so it is announced without stealing focus.
Passing a FormData object as the body sets the encoding automatically, including for file uploads.
The Constraint Validation API
form.checkValidity(); // true or false, silently
form.reportValidity(); // the same, but shows the browser messages
input.validity.valueMissing;
input.validity.typeMismatch;
input.validity.patternMismatch;
input.validity.tooShort;
input.validity.rangeOverflow;
input.validity.valid;
input.validationMessage; // the browser message text
input.setCustomValidity("Passwords do not match.");
input.setCustomValidity(""); // clear itCustom error display
<form id="signup" novalidate>
<p>
<label for="email">Email address</label>
<input type="email" id="email" name="email" required
aria-describedby="email-error">
<span id="email-error" class="error"></span>
</p>
<button type="submit">Create account</button>
</form>const form = document.getElementById("signup");
form.addEventListener("submit", (event) => {
event.preventDefault();
let firstInvalid = null;
form.querySelectorAll("input, select, textarea").forEach((field) => {
const error = document.getElementById(`${field.id}-error`);
if (!error) return;
if (field.checkValidity()) {
error.textContent = "";
field.removeAttribute("aria-invalid");
} else {
error.textContent = messageFor(field);
field.setAttribute("aria-invalid", "true");
if (!firstInvalid) firstInvalid = field;
}
});
if (firstInvalid) {
firstInvalid.focus();
return;
}
submitForm();
});
function messageFor(field) {
if (field.validity.valueMissing) return "This field is required.";
if (field.validity.typeMismatch) return "Enter a valid email address.";
if (field.validity.tooShort) return `At least ${field.minLength} characters.`;
return field.validationMessage;
}novalidate switches off the native bubbles while keeping the attributes and the validity API working - which is exactly what you want when displaying errors in the page.
Moving focus to the first invalid field is not a nicety. Without it a screen reader user is told the form failed and has no idea where.
Cross field validation
const password = document.getElementById("password");
const confirm = document.getElementById("confirm");
function checkMatch() {
confirm.setCustomValidity(
confirm.value === password.value ? "" : "Passwords do not match."
);
}
password.addEventListener("input", checkMatch);
confirm.addEventListener("input", checkMatch);The empty string is not optional. Forgetting to clear a custom message leaves the field permanently invalid, which is a bug that looks impossible until you know about it.
Useful form events
| Event | Fires |
|---|---|
submit | On the form, when it is submitted |
input | On every keystroke |
change | When a value is committed |
invalid | On a field that fails validation |
reset | When the form is reset |
focusout | When focus leaves a field - good for validating on blur |
// validate a field when the reader leaves it, not while they type
form.addEventListener("focusout", (event) => {
if (event.target.matches("input, select, textarea")) {
validateField(event.target);
}
});A live character counter
<label for="bio">Short biography</label>
<textarea id="bio" name="bio" maxlength="300" aria-describedby="bio-count"></textarea>
<p id="bio-count" aria-live="polite">300 characters remaining</p>const bio = document.getElementById("bio");
const counter = document.getElementById("bio-count");
bio.addEventListener("input", () => {
const left = bio.maxLength - bio.value.length;
counter.textContent = `${left} characters remaining`;
});A polite live region announces the update at a natural pause rather than interrupting every keystroke.
Important rules
- Always
preventDefaultin a submit handler that sends the data itself. FormDataonly includes named, enabled controls.- Client side validation is never a security boundary.
- Disable the submit button during a request and re enable it afterwards.
- Clear a custom validity message with an empty string.
- Move focus to the first invalid field.
- Never announce an error with colour alone.
Common mistakes
- Forgetting
preventDefault, so the page reloads mid request. - Reading values with
getAttribute("value")instead of the property. - Not disabling the submit button, allowing duplicate submissions.
- Errors shown only in colour, or only in a native bubble.
- Not moving focus after a failed submit.
- Forgetting to clear a custom validity message.
- Building a form from
divelements and losing every native behaviour.
Best practices
- Use a real
formwith a realactionandmethod, and enhance it with script. - Read the endpoint and method from the form rather than hardcoding them.
- Use
FormDatafor reading and sending. - Add
novalidateonly when displaying your own messages, and keep the attributes. - Put status messages in a live region.
- Validate on blur, not on every keystroke.
- Re validate everything on the server.
Practice
- Submit a form with
fetchandFormData, reading the endpoint from the form itself. - Build custom error messages using the validity API and move focus to the first failure.
- Add a password confirmation check with
setCustomValidity, then remove the clearing line and observe the bug. - Add a character counter in a polite live region and listen to it with a screen reader.