Module: JavaScript
JavaScript·074·6 MIN READ

074: Forms, FormData, and Client-Side Validation

TOPICS COVERED: Forms, FormData, and Client-Side Validation

Outcomes

By the end of this lesson, you can:

  • read normalized form values with FormData;
  • start with native HTML constraints such as required, type, and minlength;
  • inspect the Constraint Validation API;
  • separate validation decisions from DOM error rendering;
  • provide clear, associated, accessible error messages; and
  • explain why all security validation must be repeated on the server.

Retrieval Warm-Up

  1. Which event should handle every normal way a form can be submitted?
  2. Why does preventDefault() belong only where code replaces a default action?
  3. What can FormData.get() return besides a string?

Terms

Mental Model: HTML First, JavaScript Enhancement, Server Authority

Validation has three layers:

  1. HTML constraints express common rules declaratively and work before custom JavaScript.
  2. JavaScript adds application-specific checks and accessible custom feedback.
  3. The server repeats every required and security-sensitive check because requests can bypass the page.

Keep data logic and display logic distinct:

text
read + normalize -> validate -> errors object
errors object -> render messages
no errors -> submit/use data

A validator should ideally return information rather than directly editing the page. That makes its behavior easy to test and keeps DOM work in one place.

Self-Study Example: Registration Form

Create this complete index.html:

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Create an account</title>
    <link rel="stylesheet" href="styles.css">
    <script src="app.js" defer></script>
  </head>
  <body>
    <main>
      <h1>Create an account</h1>
      <p>All fields are required.</p>
      <div id="error-summary" tabindex="-1" role="alert" hidden></div>

      <form id="registration" novalidate>
        <div>
          <label for="name">Name</label>
          <input id="name" name="name" autocomplete="name" required minlength="2" aria-describedby="name-error">
          <p id="name-error" class="error"></p>
        </div>

        <div>
          <label for="email">Email</label>
          <input id="email" name="email" type="email" autocomplete="email" required aria-describedby="email-error">
          <p id="email-error" class="error"></p>
        </div>

        <div>
          <label for="password">Password</label>
          <input id="password" name="password" type="password" autocomplete="new-password" required minlength="12" aria-describedby="password-help password-error">
          <p id="password-help">Use at least 12 characters. A password manager is welcome.</p>
          <p id="password-error" class="error"></p>
        </div>

        <div>
          <label for="confirm-password">Confirm password</label>
          <input id="confirm-password" name="confirmPassword" type="password" autocomplete="new-password" required aria-describedby="confirm-password-error">
          <p id="confirm-password-error" class="error"></p>
        </div>

        <button type="submit">Create account</button>
      </form>

      <p id="status" role="status"></p>
    </main>
  </body>
</html>

Add styles.css:

css
body { max-width: 38rem; margin: auto; padding: 1rem; font-family: system-ui, sans-serif; }
label { display: block; font-weight: 700; }
input { box-sizing: border-box; width: 100%; font: inherit; padding: 0.5rem; }
input[aria-invalid="true"] { border: 3px solid #a40000; }
.error { color: #8b0000; min-height: 1.5em; }
a:focus, button:focus-visible, input:focus-visible { outline: 3px solid #5b2c6f; outline-offset: 3px; }
#error-summary { border: 3px solid #a40000; padding: 1rem; margin-block: 1rem; }

Add app.js:

js
const form = document.querySelector("#registration");
const summary = document.querySelector("#error-summary");
const status = document.querySelector("#status");

const fields = {
  name: document.querySelector("#name"),
  email: document.querySelector("#email"),
  password: document.querySelector("#password"),
  confirmPassword: document.querySelector("#confirm-password"),
};

const errorElements = {
  name: document.querySelector("#name-error"),
  email: document.querySelector("#email-error"),
  password: document.querySelector("#password-error"),
  confirmPassword: document.querySelector("#confirm-password-error"),
};

function readRegistration(formElement) {
  const data = new FormData(formElement);
  return {
    name: String(data.get("name") ?? "").trim(),
    email: String(data.get("email") ?? "").trim(),
    password: String(data.get("password") ?? ""),
    confirmPassword: String(data.get("confirmPassword") ?? ""),
  };
}

function validateRegistration(values) {
  const errors = {};

  if (values.name === "") {
    errors.name = "Enter your name.";
  } else if (values.name.length < 2) {
    errors.name = "Name must contain at least 2 characters.";
  }

  if (values.email === "") {
    errors.email = "Enter your email address.";
  } else if (!fields.email.validity.valid) {
    errors.email = "Enter an email address in the expected format, such as name@example.com.";
  }

  if (values.password === "") {
    errors.password = "Enter a password.";
  } else if (values.password.length < 12) {
    errors.password = "Password must contain at least 12 characters.";
  }

  if (values.confirmPassword === "") {
    errors.confirmPassword = "Confirm your password.";
  } else if (values.confirmPassword !== values.password) {
    errors.confirmPassword = "The passwords do not match.";
  }

  return errors;
}

function renderErrors(errors) {
  for (const [name, field] of Object.entries(fields)) {
    const message = errors[name] ?? "";
    errorElements[name].textContent = message;
    field.setAttribute("aria-invalid", String(message !== ""));
  }

  summary.replaceChildren();
  const entries = Object.entries(errors);

  if (entries.length === 0) {
    summary.hidden = true;
    return;
  }

  const heading = document.createElement("h2");
  heading.textContent = `${entries.length} ${entries.length === 1 ? "error" : "errors"} to fix`;
  const list = document.createElement("ul");

  for (const [name, message] of entries) {
    const item = document.createElement("li");
    const link = document.createElement("a");
    link.href = `#${fields[name].id}`;
    link.textContent = message;
    item.append(link);
    list.append(item);
  }

  summary.append(heading, list);
  summary.hidden = false;
}

form.addEventListener("submit", (event) => {
  event.preventDefault();
  status.textContent = "";

  const values = readRegistration(form);
  const errors = validateRegistration(values);
  renderErrors(errors);

  if (Object.keys(errors).length > 0) {
    summary.focus();
    return;
  }

  status.textContent = `Account details for ${values.name} are ready to send securely.`;
  form.reset();
  renderErrors({});
});

novalidate disables the browser's automatic popup/reporting during submission so this lesson can render consistent custom messages. It does not disable validity, checkValidity(), CSS validity states, or our HTML constraints. In many applications, native validation without novalidate is the simpler accessible starting point.

The example never echoes the password, logs it, or stores it. A real application sends it over HTTPS to a server designed to handle credentials securely.

Understanding validity

Common flags include:

js
email.validity.valueMissing
email.validity.typeMismatch
password.validity.tooShort
email.validity.valid

form.checkValidity() returns a boolean and fires invalid events on failing controls. form.reportValidity() also asks the browser to present its validation UI. input.setCustomValidity(message) sets a custom failure; always clear it with setCustomValidity("") when the condition is resolved or the field remains invalid forever.

Our validator uses native email parsing through fields.email.validity.valid instead of a simplistic regular expression. An email input validates syntax, not whether the address exists.

Intermediate Example: Validate One Field After Interaction

Do not show a wall of errors before the form is used. Validate on blur or after a submit attempt, and clear an error as the user fixes it:

js
let submittedOnce = false;

form.addEventListener("submit", (event) => {
  event.preventDefault();
  submittedOnce = true;
  // Continue with the guided submit logic.
});

form.addEventListener("input", (event) => {
  if (!submittedOnce || !(event.target instanceof HTMLInputElement)) {
    return;
  }

  const values = readRegistration(form);
  renderErrors(validateRegistration(values));
});

For a production form, preserve the summary while updating it carefully and test screen reader verbosity. Immediate feedback can help, but aggressive live alerts on every keystroke can hinder users.

Optional Advanced Example: A Pure Password-Match Validator

Pure functions accept values and return results without DOM access:

js
function validatePasswordMatch(password, confirmation) {
  if (confirmation === "") return "Confirm your password.";
  if (password !== confirmation) return "The passwords do not match.";
  return "";
}

console.assert(validatePasswordMatch("abcdefghijkl", "") !== "");
console.assert(validatePasswordMatch("abcdefghijkl", "different") !== "");
console.assert(validatePasswordMatch("abcdefghijkl", "abcdefghijkl") === "");

This is easier to test than a function that searches and edits several nodes. Our full validator could similarly receive a native email-validity boolean rather than reading fields.email, making it fully pure.

Mistakes and Debugging

  • Treating client validation as security: an attacker can send requests directly. The server must normalize, validate, authorize, and safely store data.
  • Relying on placeholder as a label: placeholder text disappears and is not a label. Use <label>.
  • Displaying only a red border: errors need text that identifies the field and suggests correction.
  • Using one giant regex for email/password: use native types for standard syntax and explain actual product requirements. Long passphrases and password managers should work.
  • Forgetting to clear custom validity: call setCustomValidity("") before reevaluating.
  • Trimming passwords: spaces may be intentional password characters. This example trims name/email, not password.
  • Moving focus on every error update: focus the summary after failed submission, not on each keystroke.
  • Injecting error/value strings with innerHTML: use createElement() and textContent.

Inspect field.validity in DevTools. Test empty, too-short, malformed, and mismatched values separately. Use the accessibility tree to confirm labels and descriptions, then complete the form using only the keyboard.

Accessibility, Security, and Performance

Accessibility: provide visible labels and up-front instructions. Associate field errors with aria-describedby; synchronize aria-invalid. A linked error summary gives overview and navigation, and programmatic focus after failed submit ensures it is encountered. Error text must not rely only on color. Keep entered values so users can correct them; clearing a failed form is hostile.

Security: client checks are bypassable. The server must validate lengths/types, rate-limit relevant actions, use parameterized database operations, hash passwords with an appropriate password-hashing algorithm, and return safe errors. Use HTTPS. Never place credentials in URLs, logs, analytics, page markup, or localStorage.

Performance: validation is usually cheap. Avoid network calls on every keystroke; debounce only when a real asynchronous check is needed and handle stale responses. Build one error update per logical validation pass. Clarity and correctness dominate micro-optimization.

Exercises

Core

Add a required username with 3-20 characters and a linked error message.

Practice

Add a required terms checkbox. Render You must accept the terms. and link its error summary item to the checkbox.

Professional Extension

Refactor validateRegistration into a pure function by passing emailIsValid as a second argument. Add console assertions for valid and invalid cases.

Core

html
<label for="username">Username</label>
<input id="username" name="username" required minlength="3" maxlength="20" aria-describedby="username-error">
<p id="username-error" class="error"></p>

Add username to fields, errorElements, and readRegistration. In validation:

js
if (values.username.length < 3 || values.username.length > 20) {
  errors.username = "Username must contain 3 to 20 characters.";
}

Practice

html
<input id="terms" name="terms" type="checkbox" required aria-describedby="terms-error">
<label for="terms">I accept the terms</label>
<p id="terms-error" class="error"></p>

Add it to the maps. Read it with terms: data.has("terms"), then validate:

js
if (!values.terms) {
  errors.terms = "You must accept the terms.";
}

Professional Extension

js
function validateRegistration(values, emailIsValid) {
  const errors = {};
  // Existing checks, but email uses:
  if (values.email === "") {
    errors.email = "Enter your email address.";
  } else if (!emailIsValid) {
    errors.email = "Enter an email address in the expected format, such as name@example.com.";
  }
  // Remaining checks...
  return errors;
}

const errors = validateRegistration(values, fields.email.validity.valid);
console.assert(validateRegistration({ name: "", email: "", password: "", confirmPassword: "" }, false).name);

Recap

  • Express standard constraints in HTML first.
  • Normalize values according to their meaning; do not blindly trim everything.
  • Use the Constraint Validation API for native validity information and reporting.
  • Return an errors object, then render clear associated feedback.
  • Preserve values and guide focus after failed submission.
  • Client validation improves UX; the server remains the security authority.

Official References