FullStack Course LogoFullStack Course
Module: JavaScript
JavaScript·074·7 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;
  • keep validation decisions separate from DOM error rendering;
  • provide clear, associated, accessible error messages; and
  • explain why every security-sensitive validation check must run again on the server.

Retrieval Warm-Up

Before you begin, try to answer these from memory:

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

Terms

  • Constraint: A validation rule attached through attributes such as required, minlength, or pattern. — Source: WHATWG HTML: Constraints
  • Constraint Validation API: The scripted validity-checking API, including checkValidity(), reportValidity(), and setCustomValidity(). — Source: MDN: Constraint validation API
  • ValidityState: An object that exposes boolean flags for individual constraints, along with the overall valid result. — Source: MDN: ValidityState
  • Client-side validation: Immediate browser feedback before submission. It is a convenience for the user, not a security boundary. — Source: MDN: Client-side validation
  • Server-side validation: Authoritative revalidation performed after the server receives the request. — Source: WHATWG HTML: Constraints
  • Normalization: Trimming or canonicalizing raw input before validation rules are applied. — Source: MDN: Client-side validation
  • Error summary: A grouped message block that lists failed fields and links to them for keyboard users. — Source: W3C WAI: Forms tutorial — Notifications
  • aria-describedby: An attribute that associates supplementary instructions or error text with a form control. — Source: MDN: aria-describedby
  • Constraint Validation API (official): "The Constraint Validation API provides methods and properties to check form control validity." — Source: MDN: Constraint validation
  • Normalization (official): "Normalization converts input to a canonical form (e.g., trimming whitespace) before validation." — Source: WHATWG: Constraint validation — Normalization

Mental Model: HTML First, JavaScript Enhancement, Server Authority

The easiest way to reason about validation is as three layers rather than one large JavaScript function:

  1. HTML constraints state common rules declaratively and provide a baseline before custom JavaScript runs.
  2. JavaScript adds application-specific checks and controls the custom feedback presented to the user.
  3. The server repeats every required and security-sensitive check, because a request can be created without using this page at all.

Keep the data decisions separate from the display work. The flow should look like this:

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

A validator is easier to test when it returns information instead of editing the page itself. Rendering can then happen in one place, while the validation rules remain understandable independently of the DOM.

Self-Study Example: Registration Form

Start with 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; }

Now 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({});
});

The form uses novalidate so the browser does not display its automatic validation popup during submission. That lets this lesson render consistent custom messages. novalidate does not turn off validity, checkValidity(), CSS validity states, or the constraints declared in the HTML. In many applications, leaving out novalidate and starting with native validation is the simpler accessible choice.

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

Understanding validity

When a form behaves unexpectedly, inspect the control's individual validity flags instead of treating validity as a single unexplained result. 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 controls that fail validation. form.reportValidity() does that check and also asks the browser to show its validation UI. Calling input.setCustomValidity(message) creates a custom validation failure; when the condition is fixed, always clear it with setCustomValidity(""). Otherwise, the field can remain invalid even after its visible value looks correct.

The validator above relies on the browser's native email parsing through fields.email.validity.valid, rather than attempting to maintain a simplistic regular expression. An email input checks syntax. It cannot establish that the address exists or that the person submitting the form controls it.

Intermediate Example: Validate One Field After Interaction

Showing every error before the user has interacted with the form creates a wall of noise. A better starting point is to validate on blur, or after the user has tried to submit, and then remove an error as the underlying value becomes valid:

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));
});

In production, update the summary carefully so it does not jump around or repeat too much information to a screen reader. Immediate feedback can be useful, but aggressive live alerts on every keystroke can make the form harder to use.

Optional Advanced Example: A Pure Password-Match Validator

A pure function takes values as arguments and returns a result without reaching into the DOM. That makes its behavior straightforward to test:

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 for and edits several DOM nodes. The full validator could follow the same boundary: receive a native email-validity boolean as an argument instead of reading fields.email itself, which would make the complete function pure too.

Mistakes and Debugging

  • Treating client validation as security: An attacker can send a request directly, without using the page. The server must normalize, validate, authorize, and safely store the data.
  • Relying on placeholder as a label: Placeholder text disappears and is not a label. Use a real <label>.
  • Displaying only a red border: An error needs text that identifies the field and tells the user how to correct it.
  • Using one giant regex for email/password: Use native types for standard syntax and describe the product's actual requirements. Long passphrases and password managers should work.
  • Forgetting to clear custom validity: Call setCustomValidity("") before reevaluating a condition that may have been fixed.
  • Trimming passwords: Spaces can be intentional password characters. This example trims the name and email, but not either password.
  • Moving focus on every error update: Move focus to the summary after a failed submission, not after every keystroke.
  • Injecting error/value strings with innerHTML: Build the elements with createElement() and assign text with textContent.

Inspect field.validity in DevTools while testing. Try empty, too-short, malformed, and mismatched values as separate cases. Use the accessibility tree to confirm that labels and descriptions are present, then complete the form using only the keyboard. If a result is unexpected, first determine whether the problem is in the input's native constraint state, the returned values, the validation rules, or the rendering step.

Accessibility, Security, and Performance

Accessibility: Provide visible labels and instructions before the user starts typing. Associate each field's error with that control through aria-describedby, and keep aria-invalid synchronized with the current error state. A linked error summary gives users an overview and a way to navigate to the failed fields. Focusing it after a failed submit ensures the summary is encountered. Error text must not communicate failure through color alone. Preserve entered values so users can correct them; clearing a failed form makes recovery unnecessarily hostile.

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

Performance: Validation is normally cheap. Avoid a network call on every keystroke. If a genuinely asynchronous check is needed, debounce it and handle stale responses so an older response cannot overwrite newer input. Prefer one error update per logical validation pass. For this problem, clarity and correctness matter more than micro-optimizing a few local checks.

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 what they mean; do not blindly trim every field.
  • Use the Constraint Validation API for native validity information and browser reporting.
  • Return an errors object, then render clear feedback associated with the relevant controls.
  • Preserve values and guide focus after a failed submission.
  • Client validation improves the user experience; the server remains the security authority.

Official References

Reader page: /javascript/lesson/074/forms-formdata-and-client-side-validation