Skip to content
elephantoo

Forms & validation

Lesson 15 of 34 16 min read

Read form data, built-in HTML validation, the Constraint Validation API and custom checks.


Forms are how users talk to your app: sign-ups, searches, checkouts, settings. In this lesson you'll read form data with JavaScript, use the browser's built-in validation, add custom rules with the Constraint Validation API, and show accessible error messages.

A form to work with#

HTML
<form id="signup" novalidate>
  <label for="name">Name</label>
  <input id="name" name="name" required minlength="2" autocomplete="name" />

  <label for="email">Email</label>
  <input id="email" name="email" type="email" required autocomplete="email" />

  <label for="age">Age</label>
  <input id="age" name="age" type="number" min="13" max="120" />

  <label for="password">Password</label>
  <input id="password" name="password" type="password" required minlength="8" />

  <label for="plan">Plan</label>
  <select id="plan" name="plan">
    <option value="free">Free</option>
    <option value="pro">Pro</option>
  </select>

  <label><input type="checkbox" name="terms" required /> I accept the terms</label>

  <button type="submit">Create account</button>
  <p id="status" role="status"></p>
</form>

Good forms start with good HTML: every input has a <label>, a name (used when reading data) and the right type (email, number, tel, url, date…) so mobile users get the right keyboard. autocomplete helps password managers and autofill.

Handling submit#

Listen for submit on the form (not click on the button) — it also fires when the user presses Enter:

JavaScript
const form = document.querySelector("#signup");
const status = document.querySelector("#status");

form.addEventListener("submit", (event) => {
  event.preventDefault(); // stop the page reload

  const data = new FormData(form);
  console.log(data.get("name"));   // "Ada"
  console.log(data.get("terms"));  // "on" if ticked, null if not

  const values = Object.fromEntries(data); // { name, email, age, password, plan, terms }
  status.textContent = `Welcome, ${values.name}!`;
});

Reading values

  • FormData reads every field with a name. Object.fromEntries(formData) turns it into a plain object.
  • Values are strings: convert with Number(values.age). An empty number field gives "".
  • Unchecked checkboxes are missing from FormData. For multi-selects or several checkboxes with the same name, use data.getAll("topics").
  • You can also read single fields: form.elements.email.value, checkbox.checked, select.value.

Built-in HTML validation#

The browser can validate many rules for you, with no JavaScript:

AttributeRule
requiredMust not be empty (checkbox must be ticked)
type="email" / "url"Must look like an email / URL
minlength / maxlengthText length limits
min / max / stepNumber and date ranges
patternMust match a regular expression (the whole value)
HTML
<input name="pin" inputmode="numeric" pattern="[0-9]{6}" title="6-digit PIN code" required />

Without novalidate, the browser blocks submission and shows its own bubble messages. That's a fine start, but the bubbles can't be styled and behave differently in each browser. Many apps add novalidate and use the same rules from JavaScript to show custom messages — which is what we'll do.

CSS hooks

CSS
input:user-invalid {
  border-color: crimson;
}
input:user-valid {
  border-color: seagreen;
}

:user-invalid only applies after the user has interacted with the field, so the form doesn't look angry before they've typed anything.

The Constraint Validation API#

Every input has validation built into its JavaScript object:

JavaScript
const email = document.querySelector("#email");

console.log(email.checkValidity());     // true / false
console.log(email.validity.valueMissing); // true if required and empty
console.log(email.validity.typeMismatch); // true if not a valid email
console.log(email.validationMessage);     // the browser's message

console.log(form.checkValidity());   // true only if every field is valid
form.reportValidity();               // shows the browser's messages

validity has flags such as valueMissing, typeMismatch, patternMismatch, tooShort, tooLong, rangeUnderflow, rangeOverflow and customError.

Custom rules with setCustomValidity

JavaScript
const password = document.querySelector("#password");

password.addEventListener("input", () => {
  const value = password.value;
  if (value.length >= 8 && !/\d/.test(value)) {
    password.setCustomValidity("Include at least one number.");
  } else {
    password.setCustomValidity(""); // empty string = valid
  }
});

A non-empty message makes the field invalid; you must reset it to "" when the problem is fixed.

Custom, accessible error messages#

Let's validate the whole form ourselves and show messages next to each field:

JavaScript
const form = document.querySelector("#signup");

const messages = {
  valueMissing: "This field is required.",
  typeMismatch: "Please enter a valid email address.",
  tooShort: (el) => `Use at least ${el.minLength} characters.`,
  rangeUnderflow: (el) => `Must be at least ${el.min}.`,
  rangeOverflow: (el) => `Must be at most ${el.max}.`,
};

function errorFor(field) {
  if (field.validity.customError) return field.validationMessage;
  for (const [flag, message] of Object.entries(messages)) {
    if (field.validity[flag]) {
      return typeof message === "function" ? message(field) : message;
    }
  }
  return "";
}

function showError(field) {
  const id = `${field.name}-error`;
  let el = document.getElementById(id);
  if (!el) {
    el = document.createElement("p");
    el.id = id;
    el.className = "field-error";
    field.after(el);
  }
  const message = errorFor(field);
  el.textContent = message;
  field.setAttribute("aria-invalid", message ? "true" : "false");
  field.setAttribute("aria-describedby", id);
  return message === "";
}

form.addEventListener("submit", (event) => {
  event.preventDefault();
  const fields = [...form.elements].filter((el) => el.willValidate);
  const results = fields.map(showError);
  const allValid = results.every(Boolean);

  if (!allValid) {
    fields.find((f) => !f.checkValidity())?.focus(); // jump to first error
    return;
  }
  console.log("Submitting", Object.fromEntries(new FormData(form)));
});

// Re-check a field as soon as the user leaves it
form.addEventListener("focusout", (event) => {
  if (event.target.willValidate) showError(event.target);
});

Accessibility details that matter:

  • aria-invalid="true" tells screen readers the field has a problem.
  • aria-describedby links the field to its error text, so it's read out.
  • Moving focus to the first invalid field helps keyboard users.
  • Validate on blur/focusout or submit — not on every keystroke, which is distracting.

Pure validation functions#

Keep your rules in plain functions — they're easy to test in Node and can be shared with the server:

JavaScript
function validateSignup({ name = "", email = "", age = "", password = "" }) {
  const errors = {};
  if (name.trim().length < 2) errors.name = "Name must be at least 2 characters.";
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = "Enter a valid email.";
  if (age !== "" && (Number(age) < 13 || Number(age) > 120)) errors.age = "Age must be 13–120.";
  if (password.length < 8) errors.password = "Password must be at least 8 characters.";
  else if (!/\d/.test(password)) errors.password = "Include at least one number.";
  return errors;
}

console.log(validateSignup({ name: "Ada", email: "ada@example.com", age: "36", password: "engine1843" }));
console.log(validateSignup({ name: "A", email: "nope", age: "9", password: "short" }));
Output
{}
{
  name: 'Name must be at least 2 characters.',
  email: 'Enter a valid email.',
  age: 'Age must be 13–120.',
  password: 'Password must be at least 8 characters.'
}

Submitting with fetch#

After validation, send the data to a server (you'll learn fetch properly in fetch, APIs & JSON):

JavaScript
form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const button = form.querySelector("button[type=submit]");
  button.disabled = true; // prevent double submits
  try {
    const res = await fetch("/api/signup", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(Object.fromEntries(new FormData(form))),
    });
    if (!res.ok) throw new Error(`Server said ${res.status}`);
    form.reset();
    status.textContent = "Account created!";
  } catch (err) {
    status.textContent = `Something went wrong: ${err.message}`;
  } finally {
    button.disabled = false;
  }
});

Other useful form events#

JavaScript
const search = document.querySelector("#search");
search.addEventListener("input", () => console.log("typing:", search.value)); // every keystroke
search.addEventListener("change", () => console.log("committed:", search.value)); // on blur/Enter

form.addEventListener("reset", () => console.log("Form cleared"));

Common mistakes#

  • Listening for click on the submit button instead of submit on the form.
  • Forgetting name attributes — FormData ignores unnamed fields.
  • Treating number inputs' values as numbers — they're strings.
  • Forgetting setCustomValidity(""), leaving a field permanently invalid.
  • Trusting client-side validation for security. Always validate on the server too.
  • Placeholder text instead of labels — placeholders disappear as you type and are often low-contrast.

What's next#

Next you'll make data survive a page refresh with localStorage and sessionStorage.

Check your understanding

Quick quiz

0/3 answered
  1. 1.Why call event.preventDefault() in a form's submit handler?

  2. 2.What does new FormData(form).get("email") return?

  3. 3.Where must validation *always* happen for security?

Finished reading?

Mark this lesson complete to track your progress.