JavaScript Form Validation: Better Feedback, Same Form

Introduction

Icebreaker: Think about a form you used recently. Did it help you fix mistakes while typing, or only after you clicked submit?

Scenario: You already know how to capture form data. Now we improve the user experience: show clear validation messages and guide the user before submit.

Learning objectives: You will:

  • Use the browser's built-in validity object to check if fields are valid
  • Show live error messages as the user types
  • Disable submit until all required fields pass
  • Add a small quality-of-life feature: live character counter
  • Keep code organized with small helper functions

Core Concept Overview

1. The Browser Already Knows a Lot

When an input has required, minlength, maxlength, or type="email", the browser can validate those rules for us automatically.

Each field has a .validity object that tells us what's wrong (if anything):

  • valueMissing → field is empty and required
  • tooShort → fewer characters than minlength
  • tooLong → more characters than maxlength
  • typeMismatch → wrong type (e.g., not a valid email)
  • customError → we set a custom error with setCustomValidity()

This means we do less manual rule writing and more clear, live feedback.

2. Native Rules First, JavaScript Second

Let HTML attributes do the heavy lifting. Then use JavaScript to:

  • read the validity state
  • collect errors from all fields
  • show them to the user
  • manage button state

Hands-On Application

Commit after each major step.

Learning workflow for this lesson:

  1. Try each step yourself first, using the instructions and code snippets. You should be typing ⌨️ at least some of the code yourself, not just copy-pasting. Use your discretion to decide which parts to type vs. copy.
  2. If stuck, open the hint accordion.
  3. Write your own explanation by hand before checking the hint explanation. This helps you internalize the concept instead of just reading it passively. After reading the hint, note any differences between your explanation and the hint, and update your understanding accordingly.

Try It 1: Form Markup with Validation Attributes

Add this to index.html:

<form
  id="join-form"
  novalidate
  class="mx-auto max-w-md space-y-4 rounded border p-4"
>
  <div>
    <label class="mb-1 block text-sm font-medium" for="username">Username</label>
    <input
      id="username"
      name="username"
      required
      minlength="3"
      maxlength="16"
      class="w-full rounded border px-3 py-2"
    />
  </div>

  <div>
    <label class="mb-1 block text-sm font-medium" for="email">Email</label>
    <input
      id="email"
      name="email"
      type="email"
      required
      class="w-full rounded border px-3 py-2"
    />
  </div>

  <div>
    <label class="mb-1 block text-sm font-medium" for="bio">Bio (optional)</label>
    <textarea
      id="bio"
      name="bio"
      maxlength="120"
      rows="4"
      class="w-full rounded border px-3 py-2"
    ></textarea>
    <p id="bio-counter" class="mt-1 text-xs text-gray-600"></p>
  </div>

  <button
    type="submit"
    class="rounded bg-blue-600 px-4 py-2 text-white disabled:bg-gray-400"
  >
    Join
  </button>
</form>

<section id="validation-errors" class="mx-auto mt-4 max-w-md"></section>
<section id="submitted" class="mx-auto mt-6 max-w-md"></section>

At the bottom of index.html, load your practice file:

<script type="module" src="./src/practice.js"></script>

Checkpoint: What does novalidate do in the form tag above?


Try It 2: Read One Field's Validity

Let's start simple. We'll read the validity object from one field and just log it.

Add this to src/practice.js:

import "./style.css";

const form = document.querySelector("#join-form");
const username = form.username;

form.addEventListener("input", () => {
  console.log(username.validity);
});

Test: Open the console, type in the username field, and watch the validity object.

Notice:

  • valid: true/false
  • valueMissing: true/false
  • tooShort: true/false

Take a screenshot of the console showing the validity object.

What is .validity?

Each input element has a .validity object that the browser manages automatically. The browser reads HTML attributes like required, minlength, and type="email", then updates the validity properties as the user types. You don't calculate validity yourself—the browser does it for free.


Try It 3: Show One Error Message

Now let's show an error message if username is too short.

Replace the code in src/practice.js:

// NOTE: Vite allows us to import CSS directly in JS. It bundles it seamlessly.
import "./style.css";

const form = document.querySelector("#join-form");
const errorsBox = document.querySelector("#validation-errors");
const username = form.username;

form.addEventListener("input", () => {
  const validity = username.validity;

  // Start fresh each time.
  errorsBox.innerHTML = "";

  // Check if the field is valid.
  if (validity.valid) return;

  // Check specific problems.
  if (validity.valueMissing)
    errorsBox.innerHTML = `<p class="text-red-700">Username is required.</p>`;

  if (validity.tooShort)
    errorsBox.innerHTML = `<p class="text-red-700">Username must be at least 3 characters.</p>`;
});

Test: Leave username blank → see "required" message. Type 1–2 characters → see "too short" message.

Why clear errorsBox first?

Each time the user types, this listener runs again. If we don't clear the old message, we'll stack up multiple messages in the DOM. Clearing first keeps it fresh.


Try It 4: Check All Fields at Once

We can't write that code for every field. Let's create a helper function that loops through all fields and collects errors from all of them.

Replace the code in src/practice.js:

import "./style.css";

const form = document.querySelector("#join-form");
const errorsBox = document.querySelector("#validation-errors");

function gatherErrors() {
  const errors = [];

  const fields = Array.from(form.elements).filter((f) => f.name);

/** 
 * No need for `map`. We actually just want to MUTATE the `errors` array by 
 * pushing new messages into it.
 * 
 * `map` is for transforming arrays, 
 * and it would create a new array that we would ignore. 
 * 
 * A simple `forEach` is more appropriate here since we're not
 * using the return value of the loop.
 */
  fields.forEach((field) => {
    const validity = field.validity;

    if (validity.valueMissing)
      errors.push(`${field.name} is required.`);

    if (validity.tooShort)
      errors.push(
        `${field.name} must be at least ${field.getAttribute("minlength")} characters.`,
      );

    if (validity.tooLong)
      errors.push(
        `${field.name} must be at most ${field.getAttribute("maxlength")} characters.`,
      );

    if (validity.typeMismatch)
      errors.push(`${field.name} must be a valid ${field.type}.`);

    if (validity.customError) errors.push(field.validationMessage);
  });

  return errors;
}

form.addEventListener("input", () => {
  const errors = gatherErrors();

  errorsBox.innerHTML = errors.length
    ? `<ul class="space-y-1">${errors.map((e) => `<li class="text-red-700">${e}</li>`).join("")}</ul>`
    : "";
});

Test: Fill fields invalidly. Watch all errors appear live.

What's Array.from(form.elements).filter((f) => f.name)?

form.elements is a list of all inputs/buttons/textareas in the form. We convert it to an array with Array.from() so we can use .filter(). Then we keep only fields with a name attribute (because buttons don't have names and we don't care about them).


Try It 5: Disable Submit Until Valid

Now prevent submit if errors exist.

Add this below the gatherErrors() function:

const submitBtn = form.querySelector('[type="submit"]');

function updateSubmitButton() {
  submitBtn.disabled = gatherErrors().length > 0;
}

form.addEventListener("input", () => {
  const errors = gatherErrors();
  errorsBox.innerHTML = errors.length
    ? `<ul class="space-y-1">${errors.map((e) => `<li class="text-red-700">${e}</li>`).join("")}</ul>`
    : "";

  updateSubmitButton();
});

// Start disabled.
updateSubmitButton();

Test: Button is grayed out initially. Fill fields → button enables. Break a field → button disables again.

Why call updateSubmitButton() at the very end?

If you skip this, the button starts enabled, then only disables when the user first types. That's bad UX. Starting disabled signals to the user: "I need to fill something before I can go."


Try It 6: Add Custom Validation + Character Counter

Let's add two small features:

  1. A custom rule: no spaces in username
  2. A character counter for the bio field

Add this before the input listener:

const username = form.username;
const bio = form.bio;
const bioCounter = document.querySelector("#bio-counter");
const maxChars = Number(bio.getAttribute("maxlength"));

username.addEventListener("input", () => {
  // Clear old custom errors first.
  username.setCustomValidity("");

  // Custom rule: no spaces allowed.
  if (username.value.includes(" "))
    username.setCustomValidity("Spaces are not allowed.");
});

bio.addEventListener("input", () => {
  const used = bio.value.length;
  const remaining = maxChars - used;
  bioCounter.textContent = `${remaining} characters remaining`;
});

Test: Type a space in username → custom message appears in errors. Type in bio → counter updates.

Why setCustomValidity first, then check?

Custom errors stick around until you explicitly clear them with setCustomValidity(""). If you don't clear first, the field stays invalid even after the user fixes it. Always clear before setting.


Try It 7: Submit & Show Success

Finally, add a submit handler:

const submittedBox = document.querySelector("#submitted");

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

  const errors = gatherErrors();

  /**
   * We don't need `> 0` because an empty array is falsy, 
   * and a non-empty array is truthy. 
  */
  if (errors.length) 
    // Errors already rendered by the input listener. Just bail.
    return;

  const data = Object.fromEntries(new FormData(form));

  submittedBox.innerHTML = `
    <div class="rounded border bg-green-50 p-4">
      <h2 class="mb-2 font-semibold">Welcome!</h2>

      <pre class="rounded border bg-white p-2 text-xs">
      ${JSON.stringify(data, null, 2)}</pre>
    </div>
  `;
});

What is JSON.stringify(data, null, 2) doing? It converts the form data object into a nicely formatted string for display. The null means we don't want to transform the data, and 2 means we want to indent with 2 spaces for readability.

JSON is a built-in JavaScript object that provides methods for working with JSON data. JSON.stringify() converts a JavaScript object into a JSON string, which is useful for displaying or sending data.

Test: Fill all fields validly → click submit → success message appears.

Why check errors again on submit?

The button is usually disabled, but it's defensive coding. Edge cases happen. Re-checking on submit is a safety net and keeps behavior predictable.


Wrap-Up & Assessment

Key Takeaways

  • Use the .validity object to read what's wrong with each field.
  • Collect errors in one helper function so you don't repeat code.
  • Show errors live as the user types—better UX than waiting until submit.
  • Always check errors again on submit, even if the button is disabled.
  • Small, focused helper functions keep your code readable.

Reflection Questions

  1. Why does the lesson use novalidate on the form tag?
  2. How would you add a new field (e.g., age) to the form? What attributes would you use?
  3. What happens if you remove the updateSubmitButton() call at the end of Try It 5? Try it and describe what you see.
  4. In Try It 6, why do we call setCustomValidity("") before checking for spaces?

Submission Checklist

  • All 7 Try Its completed and tested
  • Screenshots of working form (invalid state + valid state)
  • Screenshot of console showing validity object (Try It 2)
  • At least 3 meaningful commits with descriptive messages
  • Reflection questions answered in writing

Looking Ahead

Next lesson introduces Promises + Fetch so you can load and render JSON data from a server.