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.

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 keys

FormData 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 it

Custom 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

EventFires
submitOn the form, when it is submitted
inputOn every keystroke
changeWhen a value is committed
invalidOn a field that fails validation
resetWhen the form is reset
focusoutWhen 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 preventDefault in a submit handler that sends the data itself.
  • FormData only 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 div elements and losing every native behaviour.

Best practices

  • Use a real form with a real action and method, and enhance it with script.
  • Read the endpoint and method from the form rather than hardcoding them.
  • Use FormData for reading and sending.
  • Add novalidate only 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

  1. Submit a form with fetch and FormData, reading the endpoint from the form itself.
  2. Build custom error messages using the validity API and move focus to the first failure.
  3. Add a password confirmation check with setCustomValidity, then remove the clearing line and observe the bug.
  4. Add a character counter in a polite live region and listen to it with a screen reader.

Useful resources

Hand picked references for this topic
Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All HTML notes →

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.