Skip to main content

πŸ“ Form Events and Validation

Forms are where users hand you their data β€” signing up, logging in, checking out, sending a message. Handle them well and the experience feels effortless; handle them poorly and users bounce. This lesson covers the events forms fire and how to validate input clearly, kindly, and accessibly.

Week 2 · Day 3 (Wednesday: Events and Event Handling) · Lecture 3

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Handle the core form events: input, change, focus, blur, and submit
  • Read submitted data cleanly with FormData and prevent the default page reload
  • Use HTML5 validation attributes and the Constraint Validation API
  • Write custom real-time validation that gives immediate, specific feedback
  • Validate across fields (e.g. matching passwords) and debounce expensive checks
  • Report errors accessibly with aria-invalid, aria-describedby, and live regions

Estimated Time: 70 minutes

Practice: Build a sign-up form with live validation and an accessible error summary.

In This Lesson

Forms Are Conversations

Think of a form as a back-and-forth conversation. The user types (a message to you), and a good form replies right away β€” "that email looks valid," "passwords don't match yet," "you have 40 characters left." Each user action is a form event you can respond to, and each response is a chance to guide them to a successful submission.

graph TD A[User interacts with a field] --> B{Which event?} B --> C[input — every keystroke] B --> D[change — value committed] B --> E[blur — field left] B --> F[submit — form sent] C --> G[Real-time feedback] D --> G E --> H[Validate the field] F --> I[Validate the whole form] G --> J[Clear, kind UI response] H --> J I --> J

⚠️ Client-side validation is UX, not security

JavaScript validation makes forms pleasant, but it can be bypassed entirely (users can disable JS or hit your API directly). Always validate again on the server. Client checks improve the experience; server checks protect your data.

The Core Form Events

Five events cover the vast majority of form work. Knowing exactly when each fires is the whole game.

EventFires when…Best for
inputOn every value change (each keystroke, paste, autofill)Live validation, counters, search-as-you-type
changeWhen the value is committed (blur for text; immediately for selects/checkboxes)Dropdowns, radios, checkboxes
focusA field gains focusShowing hints, highlighting the active field
blurA field loses focusValidating a field once the user moves on
submitThe form is submitted (button click or Enter)Final validation and sending data

input vs change

const search = document.querySelector('#search');

// input: fires on EVERY change β€” great for live feedback
search.addEventListener('input', (e) => {
    filterResults(e.target.value);
});

// change: fires only when the value is "committed"
const country = document.querySelector('#country');
country.addEventListener('change', (e) => {
    loadStatesFor(e.target.value);   // ideal for a select
});

A live character counter

const message = document.querySelector('#message');
const counter = document.querySelector('#char-count');
const MAX = 280;

message.addEventListener('input', (e) => {
    const remaining = MAX - e.target.value.length;
    counter.textContent = `${remaining} characters remaining`;
    counter.classList.toggle('low', remaining < 20);   // style via CSS, not inline
});

Focus & blur for guidance

document.querySelectorAll('.field input').forEach((input) => {
    input.addEventListener('focus', (e) => {
        e.target.closest('.field').classList.add('is-focused');
    });
    input.addEventListener('blur', (e) => {
        e.target.closest('.field').classList.remove('is-focused');
        validateField(e.target);      // validate once they move on
    });
});

πŸ’‘ Validate on blur, re-validate on input

Validating on every keystroke as someone first types feels naggy ("invalid email" while they're mid-word). A friendlier pattern: validate a field on blur, then β€” once it has shown an error β€” switch to live input validation so the error clears the instant they fix it.

Submit & FormData

The submit event is the moment of truth. Attach it to the <form> (not the button) so both clicking Submit and pressing Enter are caught, call preventDefault() to stop the full-page reload, and read the fields with FormData.

const form = document.querySelector('#contact-form');

form.addEventListener('submit', async (e) => {
    e.preventDefault();                       // no page reload

    // FormData gathers every field with a `name` attribute
    const data = new FormData(form);

    // Read individual values…
    const email = data.get('email');
    // …or convert the whole form to a plain object:
    const payload = Object.fromEntries(data.entries());

    // Send it with fetch (covered fully in a later lesson)
    const res = await fetch(form.action, {
        method: 'POST',
        body: data
    });

    if (res.ok) {
        form.reset();
        showMessage('Thanks! We got your message.', 'success');
    } else {
        showMessage('Something went wrong. Please try again.', 'error');
    }
});

βœ… Give the submit button a loading state

Disable the submit button and show "Sending…" while the request is in flight. It prevents double submissions and reassures the user something is happening.

const btn = form.querySelector('button[type="submit"]');
btn.disabled = true;
btn.textContent = 'Sending…';
// …after the fetch resolves (in a finally block):
btn.disabled = false;
btn.textContent = 'Send';

HTML5 & the Validation API

Before writing any JavaScript, lean on the browser. HTML5 gives you declarative validation attributes for free, and they're accessible and localized out of the box.

<form id="signup">
    <input type="text"  name="username" required minlength="3" maxlength="20">
    <input type="email" name="email" required>
    <input type="text"  name="zip" pattern="[0-9]{5}" title="5-digit ZIP code">
    <input type="number" name="age" min="18" max="120">
    <input type="url"   name="website" placeholder="https://example.com">
    <button type="submit">Create account</button>
</form>

The Constraint Validation API lets JavaScript read and control this built-in validity so you can style and message it yourself.

const form = document.querySelector('#signup');

form.addEventListener('submit', (e) => {
    // checkValidity() returns false and fires 'invalid' on bad fields
    if (!form.checkValidity()) {
        e.preventDefault();
        form.reportValidity();     // show the browser's native messages
    }
});

// Inspect a single field's validity state
const username = form.elements.username;
username.addEventListener('invalid', (e) => {
    const v = e.target.validity;
    if (v.valueMissing)   e.target.setCustomValidity('Username is required.');
    else if (v.tooShort)  e.target.setCustomValidity('At least 3 characters, please.');
    else                  e.target.setCustomValidity('');   // clear
});
// Always clear the custom message on input, or it sticks:
username.addEventListener('input', (e) => e.target.setCustomValidity(''));

πŸ“– Handy ValidityState flags

valueMissing (required but empty), typeMismatch (bad email/url), patternMismatch, tooShort/tooLong, rangeUnderflow/rangeOverflow, and valid. Read them off input.validity to craft precise messages.

Custom Real-Time Validation

When you need rules HTML5 can't express β€” password strength, "username already taken," matching fields β€” write your own. A clean approach keeps rules as small, testable functions and reuses one display routine.

A reusable field validator

// Each rule: a test function + the message to show when it fails.
const rules = {
    username: [
        { test: (v) => v.length >= 3,            msg: 'At least 3 characters.' },
        { test: (v) => /^[a-zA-Z0-9_]+$/.test(v), msg: 'Letters, numbers, and _ only.' }
    ],
    email: [
        { test: (v) => v.trim() !== '',          msg: 'Email is required.' },
        { test: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v), msg: 'Enter a valid email.' }
    ],
    password: [
        { test: (v) => v.length >= 8,  msg: 'At least 8 characters.' },
        { test: (v) => /[A-Z]/.test(v), msg: 'Add an uppercase letter.' },
        { test: (v) => /[0-9]/.test(v), msg: 'Add a number.' }
    ]
};

function validateField(field) {
    const fieldRules = rules[field.name] || [];
    for (const rule of fieldRules) {
        if (!rule.test(field.value)) {
            showError(field, rule.msg);      // first failing rule wins
            return false;
        }
    }
    clearError(field);
    return true;
}

Wiring it up (blur first, then live)

const form = document.querySelector('#signup');

Object.keys(rules).forEach((name) => {
    const field = form.elements[name];
    // Validate when they leave the field…
    field.addEventListener('blur', () => validateField(field));
    // …and re-check live once an error is showing, so it clears as they fix it.
    field.addEventListener('input', () => {
        if (field.classList.contains('invalid')) validateField(field);
    });
});

form.addEventListener('submit', (e) => {
    const allValid = Object.keys(rules)
        .map((name) => validateField(form.elements[name]))
        .every(Boolean);                 // validate every field, don't short-circuit

    if (!allValid) {
        e.preventDefault();
        form.querySelector('.invalid')?.focus();   // send focus to the first error
    }
});

Cross-field validation: matching passwords

const password = form.elements.password;
const confirm  = form.elements.confirmPassword;

confirm.addEventListener('input', () => {
    if (confirm.value !== password.value) {
        showError(confirm, 'Passwords do not match.');
    } else {
        clearError(confirm);
    }
});

Debounced async checks

Checking "is this username taken?" hits the server, so don't fire on every keystroke β€” debounce it to wait until typing pauses.

function debounce(fn, delay) {
    let id;
    return (...args) => {
        clearTimeout(id);
        id = setTimeout(() => fn(...args), delay);
    };
}

const checkUsername = debounce(async (value) => {
    if (value.length < 3) return;
    const res = await fetch(`/api/username-available?u=${encodeURIComponent(value)}`);
    const { available } = await res.json();
    const field = form.elements.username;
    available ? clearError(field) : showError(field, 'That username is taken.');
}, 400);

form.elements.username.addEventListener('input', (e) => checkUsername(e.target.value));

Accessible Error Handling

Validation that only looks wrong (a red border) is invisible to screen-reader users. Tie every error to its field programmatically and announce it. Here's a showError/clearError pair that does it right.

function showError(field, message) {
    field.classList.add('invalid');
    field.setAttribute('aria-invalid', 'true');

    const errorId = `${field.name}-error`;
    let error = document.getElementById(errorId);
    if (!error) {
        error = document.createElement('p');
        error.id = errorId;
        error.className = 'error-message';
        error.setAttribute('role', 'alert');       // announced immediately
        field.insertAdjacentElement('afterend', error);
    }
    error.textContent = message;
    // Link the error to the field so assistive tech reads them together
    field.setAttribute('aria-describedby', errorId);
}

function clearError(field) {
    field.classList.remove('invalid');
    field.removeAttribute('aria-invalid');
    field.removeAttribute('aria-describedby');
    document.getElementById(`${field.name}-error`)?.remove();
}

βœ… Accessible-form checklist

  • Every input has an associated <label> (via for/id)
  • Errors use aria-invalid="true" and are linked with aria-describedby
  • Error text lives in a role="alert" element so it's announced
  • On a failed submit, move focus to the first invalid field
  • Don't rely on color alone β€” pair red with an icon or text

⚠️ Never trust client input

Even with perfect client validation, sanitize and re-validate on the server. And when you inject user text into the DOM, prefer textContent over innerHTML to avoid cross-site scripting (XSS).

Practice & Quiz

πŸ‹οΈ Exercise 1: No-reload submit

Goal: Given <form id="login"> with named email and password fields, log an object of the values on submit without reloading the page.

const login = document.querySelector('#login');
// TODO: on submit, prevent reload and log { email, password }
πŸ’‘ Hint

Call e.preventDefault(), then build a FormData from the form and pass it to Object.fromEntries().

βœ… Solution
login.addEventListener('submit', (e) => {
    e.preventDefault();
    const data = Object.fromEntries(new FormData(login).entries());
    console.log(data);   // { email: '…', password: '…' }
});

πŸ‹οΈ Exercise 2: Live email validation

Goal: As the user types in #email, show "Looks good βœ“" in green when the value matches an email pattern and "Not a valid email" in red otherwise β€” but show nothing while the field is empty.

πŸ’‘ Hint

Listen for input. Use /^[^\s@]+@[^\s@]+\.[^\s@]+$/ to test. Handle three states: empty, valid, invalid. Toggle CSS classes rather than setting inline colors.

βœ… Solution
const email = document.querySelector('#email');
const note  = document.querySelector('#email-note');
const isEmail = (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);

email.addEventListener('input', (e) => {
    const v = e.target.value.trim();
    if (v === '') {
        note.textContent = '';
        note.className = 'note';
    } else if (isEmail(v)) {
        note.textContent = 'Looks good βœ“';
        note.className = 'note valid';
    } else {
        note.textContent = 'Not a valid email';
        note.className = 'note invalid';
    }
});

🎯 Quick Quiz

Question 1: Which event should a real-time character counter listen for?

Question 2: Why must you still validate on the server even with solid JavaScript validation?

Question 3: What links an error message to its input for screen readers?

Best Practices & Pitfalls

βœ… Do

  • Attach submit to the <form> and always preventDefault() when handling it yourself
  • Start with HTML5 attributes; add JavaScript only for what they can't express
  • Validate on blur, then re-validate live on input once an error shows
  • Write specific messages ("Add a number") not vague ones ("Invalid")
  • Wire errors with aria-invalid + aria-describedby and move focus to the first error
  • Debounce async checks and always re-validate on the server

❌ Don't

  • Listen for click on the submit button β€” you'll miss Enter-key submits
  • Show "invalid" on the very first keystroke while the user is still typing
  • Trust client validation as your security layer
  • Inject user input with innerHTML β€” use textContent to avoid XSS
  • Rely on color alone to signal an error

Summary

πŸŽ‰ Key Takeaways

  • Five events do the heavy lifting: input, change, focus, blur, submit
  • Handle submit on the form, call preventDefault(), and read fields with FormData
  • Start with HTML5 attributes and the Constraint Validation API before writing custom rules
  • Validate on blur, re-validate live on input; write specific error messages
  • Make errors accessible with aria-invalid, aria-describedby, and focus management
  • Client validation is UX; the server is the real gate β€” always validate there too

πŸ“š Additional Resources

πŸš€ What's Next?

You've handled synchronous events all day β€” clicks, keystrokes, submits. But how does JavaScript juggle a network request and a click at the same time without freezing? Next up: Callbacks and the Event Loop, the model behind everything asynchronous.

πŸŽ‰ Forms mastered!

You can now capture, validate, and submit user input cleanly and accessibly β€” a skill on every real app.