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? |
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.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').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
);
});
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) }) |