Forms

โ–ถ Try It Yourself

Forms are the primary way users send data to your application โ€” login, registration, search, checkout, settings. Handling forms well in JavaScript means intercepting submission, reading and validating inputs, showing useful feedback, and managing state between attempts. In this lesson you will master form element access, the FormData API, custom validation, real-time feedback with the input event, and the patterns behind every polished form experience.

Accessing Form Elements

Method Example Notes
form.elements['name'] form.elements['email'] Access by input name attribute
form.querySelector form.querySelector('#email') Standard CSS selector
new FormData(form) new FormData(form) Collect all named inputs at once
input.value emailInput.value Current value โ€” string always
input.checked checkbox.checked Boolean for checkbox/radio
select.value dropdown.value Selected option value
input.files fileInput.files[0] FileList for file inputs

Form Events

Event Fires When Use For
submit Form submitted (button click or Enter key) Validate and send data โ€” always call e.preventDefault()
input Every keystroke / value change Real-time validation and character counts
change Value changes AND focus leaves field Validate after user finishes typing
focus Field receives focus Show hints, highlight field
blur Field loses focus Validate single field on departure
reset Form reset button clicked Clear custom validation state

Constraint Validation API

Property / Method Description
input.validity.valid Boolean โ€” passes all HTML constraints
input.validity.valueMissing required field is empty
input.validity.typeMismatch Wrong format (e.g. invalid email)
input.validity.patternMismatch Doesn’t match pattern attribute
input.validity.tooShort / tooLong Below minlength or above maxlength
input.validity.rangeUnderflow / rangeOverflow Below min or above max
input.setCustomValidity(msg) Set custom error โ€” empty string clears it
input.reportValidity() Show browser validation popup
form.checkValidity() Boolean โ€” all fields valid?
Note: Always call e.preventDefault() in your form submit handler, or the browser will reload the page (GET) or navigate away (POST). After preventing the default, you control what happens next โ€” validate the data, show errors, or submit via fetch(). This is fundamental to building single-page application forms.
Tip: new FormData(form) is the cleanest way to collect all form inputs at once. It automatically reads every named input โ€” text, select, checkboxes, radio buttons, and even file inputs. Object.fromEntries(new FormData(form)) converts it to a plain object. For multi-value fields (multi-select, same-name checkboxes), use formData.getAll('name').
Warning: Never trust client-side validation alone. JavaScript validation improves the user experience by giving instant feedback โ€” but it can always be bypassed by someone submitting a raw HTTP request. Always validate and sanitise data on the server. Client-side validation is a UX layer, not a security layer.

Basic Example

// โ”€โ”€ Basic form submission โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const form = document.querySelector('#signup-form');

form.addEventListener('submit', (e) => {
    e.preventDefault();   // ALWAYS โ€” prevent page reload

    // Method 1: individual field access
    const name  = form.elements['name'].value.trim();
    const email = form.elements['email'].value.trim();

    // Method 2: FormData โ€” collect all at once
    const data  = Object.fromEntries(new FormData(form));
    console.log(data);   // { name: 'Alice', email: 'alice@...', role: 'user' }

    if (validate(data)) {
        submitToServer(data);
    }
});

// โ”€โ”€ Read different input types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const text     = form.querySelector('[name="username"]').value;      // string
const number   = Number(form.querySelector('[name="age"]').value);   // convert!
const checked  = form.querySelector('[name="agree"]').checked;       // boolean
const selected = form.querySelector('[name="country"]').value;       // option value
const files    = form.querySelector('[name="avatar"]').files;        // FileList

// โ”€โ”€ Real-time validation โ€” input event โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const emailInput = form.querySelector('[name="email"]');
const emailError = form.querySelector('#email-error');

emailInput.addEventListener('input', () => {
    const val   = emailInput.value.trim();
    const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val);

    emailInput.classList.toggle('invalid', val && !valid);
    emailInput.classList.toggle('valid',   val && valid);
    emailError.textContent = (val && !valid) ? 'Please enter a valid email' : '';
    emailError.hidden      = !emailError.textContent;
});

// โ”€โ”€ Constraint validation API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const passwordInput = form.querySelector('[name="password"]');

passwordInput.addEventListener('blur', () => {
    const val = passwordInput.value;

    if (val.length < 8) {
        passwordInput.setCustomValidity('Password must be at least 8 characters');
    } else if (!/[A-Z]/.test(val)) {
        passwordInput.setCustomValidity('Password must contain an uppercase letter');
    } else if (!/[0-9]/.test(val)) {
        passwordInput.setCustomValidity('Password must contain a number');
    } else {
        passwordInput.setCustomValidity('');   // clear โ€” field is valid
    }
    passwordInput.reportValidity();
});

// โ”€โ”€ Character counter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const bio     = form.querySelector('[name="bio"]');
const counter = form.querySelector('#bio-counter');
const MAX     = 200;

bio.addEventListener('input', () => {
    const remaining = MAX - bio.value.length;
    counter.textContent = `${remaining} characters remaining`;
    counter.classList.toggle('warning', remaining < 20);
    counter.classList.toggle('error',   remaining < 0);
});

// โ”€โ”€ FormData with file upload โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
form.addEventListener('submit', async (e) => {
    e.preventDefault();
    const formData = new FormData(form);   // includes file inputs automatically!

    const response = await fetch('/api/signup', {
        method: 'POST',
        body:   formData,   // browser sets multipart/form-data automatically
    });

    const result = await response.json();
    console.log(result);
});

How It Works

Step 1 โ€” FormData Reflects the Current State

new FormData(form) reads the current value of every named input element in the form at the moment of creation. It works on text inputs, selects, checkboxes (only if checked), radio buttons (only the selected one), and file inputs. This is the most reliable way to collect form data โ€” it handles edge cases like multi-select and file inputs automatically.

Step 2 โ€” input vs change

input fires immediately on every single character โ€” perfect for live feedback like character counters and real-time search. change fires only when the value changes AND the user moves focus away โ€” better for validating a field after the user has finished with it. Use input for live UI updates and change for post-interaction validation.

Step 3 โ€” Constraint Validation API Hooks Into the Browser

input.setCustomValidity('error message') marks the field as invalid with your custom message. setCustomValidity('') clears the error and marks it valid. reportValidity() shows the browser’s native error popup. form.checkValidity() returns false if any field has a custom error set. This hooks your custom logic into the browser’s built-in form validation flow.

Step 4 โ€” Input Values Are Always Strings

Every form input value โ€” even <input type="number"> โ€” returns a string. Always convert explicitly: Number(input.value), parseInt(input.value, 10), or parseFloat(input.value). Forgetting this leads to bugs like "5" + "3" = "53" instead of 8.

Step 5 โ€” Sending FormData with fetch

Passing a FormData object as the body of a fetch request automatically sets the Content-Type to multipart/form-data with the correct boundary. Do not set the Content-Type header manually โ€” the browser must set it to include the boundary string. File uploads work automatically this way.

Real-World Example: Multi-Step Form with Validation

// multi-step-form.js

class MultiStepForm {
    #steps;
    #current = 0;
    #data    = {};
    #form;

    constructor(formSelector) {
        this.#form  = document.querySelector(formSelector);
        this.#steps = [...this.#form.querySelectorAll('[data-step]')];
        this.#showStep(0);
        this.#attachEvents();
    }

    #attachEvents() {
        this.#form.addEventListener('click', (e) => {
            if (e.target.matches('[data-next]')) this.#next();
            if (e.target.matches('[data-prev]')) this.#prev();
        });

        this.#form.addEventListener('submit', (e) => {
            e.preventDefault();
            this.#submit();
        });
    }

    #validate(step) {
        const inputs   = [...step.querySelectorAll('[required]')];
        let   isValid  = true;

        inputs.forEach(input => {
            const error = step.querySelector(`[data-error="${input.name}"]`);
            if (!input.value.trim()) {
                input.classList.add('invalid');
                if (error) error.textContent = `${input.placeholder || input.name} is required`;
                isValid = false;
            } else {
                input.classList.remove('invalid');
                if (error) error.textContent = '';
            }
        });
        return isValid;
    }

    #collectStep(step) {
        const data = Object.fromEntries(new FormData(this.#form));
        Object.assign(this.#data, data);
    }

    #showStep(index) {
        this.#steps.forEach((step, i) => {
            step.hidden = i !== index;
            step.querySelector('[data-step-label]')?.classList.toggle('active', i === index);
        });
        this.#form.querySelector('[data-progress]').style.width =
            `${((index) / (this.#steps.length - 1)) * 100}%`;
    }

    #next() {
        const step = this.#steps[this.#current];
        if (!this.#validate(step)) return;
        this.#collectStep(step);
        if (this.#current < this.#steps.length - 1) {
            this.#current++;
            this.#showStep(this.#current);
        }
    }

    #prev() {
        if (this.#current > 0) {
            this.#current--;
            this.#showStep(this.#current);
        }
    }

    async #submit() {
        this.#collectStep(this.#steps[this.#current]);
        console.log('Submitting:', this.#data);
        // await fetch('/api/register', { method: 'POST', body: JSON.stringify(this.#data) })
    }
}

const wizard = new MultiStepForm('#signup-wizard');

Common Mistakes

Mistake 1 โ€” Forgetting to call e.preventDefault() on submit

โŒ Wrong โ€” page reloads:

form.addEventListener('submit', (e) => {
    // missing e.preventDefault() โ€” browser reloads the page
    handleForm();
});

โœ… Correct โ€” always prevent default first:

form.addEventListener('submit', (e) => {
    e.preventDefault();
    handleForm();
});

Mistake 2 โ€” Not converting number inputs from string

โŒ Wrong โ€” produces string concatenation not addition:

const qty   = form.elements['qty'].value;   // '3' โ€” string
const price = 9.99;
console.log(qty * price);   // 29.97 (OK โ€” * converts) โ€” but:
console.log(qty + 1);       // '31' not 4 โ€” + doesn't convert!

โœ… Correct โ€” convert explicitly:

const qty = Number(form.elements['qty'].value);
console.log(qty + 1);   // 4

Mistake 3 โ€” setCustomValidity without clearing on valid input

โŒ Wrong โ€” field stays invalid forever once set:

input.addEventListener('input', () => {
    if (input.value.length < 8) {
        input.setCustomValidity('Too short');
        // Never cleared when length >= 8!
    }
});

โœ… Correct โ€” always clear with empty string when valid:

input.addEventListener('input', () => {
    input.setCustomValidity(
        input.value.length < 8 ? 'Too short' : ''   // clear when valid
    );
});

▶ Try It Yourself

Quick Reference

Task Code
Intercept submit form.addEventListener('submit', e => { e.preventDefault(); ... })
Collect all fields Object.fromEntries(new FormData(form))
Read text input input.value.trim()
Read checkbox checkbox.checked
Real-time feedback input.addEventListener('input', fn)
Custom error input.setCustomValidity('msg') / '' to clear
Check all valid form.checkValidity()
Send with fetch fetch(url, { method:'POST', body: new FormData(form) })

🧠 Test Yourself

What type does <input type="number">.value return?





โ–ถ Try It Yourself