Skip to content

Forms and Form Validation API

beginner14 min read

Forms Are the Browser's Superpower (That Nobody Uses)

Here's a hot take: most JavaScript form libraries exist because developers don't know what the browser already provides. The native form APIs handle data collection, validation, error messages, and submission — all without a single npm package.

You should absolutely reach for a form library in complex React apps. But understanding the native APIs first means you'll know what your library is doing under the hood, you'll make better decisions about when you actually need one, and you'll write simpler code in vanilla JavaScript projects.

Mental Model

Think of a form as a self-contained data machine. It collects inputs, validates them against rules you define, packages the data into a neat bundle (FormData), and ships it somewhere. The browser handles the plumbing — you just configure the rules and decide what to do with the data.

FormData — The Data Extraction Tool

FormData creates a set of key/value pairs from a form, using each input's name attribute as the key.

<form id="signup">
  <input name="email" type="email" value="ada@example.com" />
  <input name="password" type="password" value="secret123" />
  <select name="role">
    <option value="dev" selected>Developer</option>
    <option value="designer">Designer</option>
  </select>
  <button type="submit">Sign Up</button>
</form>
const form = document.getElementById('signup');

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

  const data = new FormData(form);

  // Access individual values
  data.get('email');    // "ada@example.com"
  data.get('password'); // "secret123"
  data.get('role');     // "dev"

  // Convert to a plain object
  const obj = Object.fromEntries(data);
  // { email: "ada@example.com", password: "secret123", role: "dev" }

  // Or iterate over all entries
  for (const [key, value] of data) {
    console.log(key, value);
  }
});
Common Trap

FormData only captures inputs that have a name attribute. If you forget the name on an input, it's invisible to FormData. Also, disabled inputs are excluded — only enabled inputs with names are collected.

FormData Methods

const data = new FormData(form);

data.get('email');         // get a single value
data.getAll('hobbies');    // get all values for a name (checkboxes, multi-select)
data.has('email');         // check if a key exists
data.set('email', 'new@example.com'); // overwrite a value
data.append('tag', 'javascript');     // add a value (allows duplicates)
data.delete('password');   // remove a key
Quiz
You have a form with 3 checkboxes all named 'skills'. How do you get all selected values from FormData?

Constraint Validation API

HTML5 gives you built-in validation through attributes, and the Constraint Validation API lets you interact with it programmatically.

Built-in Validation Attributes

<form id="profile">
  <input name="username" required minlength="3" maxlength="20" />
  <input name="email" type="email" required />
  <input name="age" type="number" min="13" max="120" />
  <input name="website" type="url" pattern="https://.*" />
  <button type="submit">Save</button>
</form>

The browser validates these automatically when the form is submitted. If validation fails, it shows a native error bubble and prevents submission.

checkValidity and reportValidity

const form = document.getElementById('profile');
const emailInput = form.querySelector('[name="email"]');

// Check a single input
emailInput.checkValidity();   // true/false — doesn't show error UI
emailInput.reportValidity();  // true/false — SHOWS native error bubble

// Check the entire form
form.checkValidity();   // true only if ALL inputs are valid
form.reportValidity();  // shows error on first invalid input

The validity Object

Every form input has a validity property with boolean flags for each validation state:

const input = document.querySelector('[name="email"]');
const v = input.validity;

v.valid;            // true if all constraints pass
v.valueMissing;     // true if required and empty
v.typeMismatch;     // true if type="email" but value isn't an email
v.patternMismatch;  // true if doesn't match the pattern attribute
v.tooShort;         // true if shorter than minlength
v.tooLong;          // true if longer than maxlength
v.rangeUnderflow;   // true if less than min
v.rangeOverflow;    // true if greater than max
v.stepMismatch;     // true if doesn't match step
v.badInput;         // true if browser can't parse input (e.g., letters in number field)
v.customError;      // true if setCustomValidity was called with a non-empty string
Quiz
What does input.validity.typeMismatch return for an email input containing 'not-an-email'?

setCustomValidity — Custom Error Messages

const passwordInput = document.querySelector('[name="password"]');

passwordInput.addEventListener('input', () => {
  if (passwordInput.value.length < 8) {
    passwordInput.setCustomValidity('Password must be at least 8 characters');
  } else if (!/[A-Z]/.test(passwordInput.value)) {
    passwordInput.setCustomValidity('Password must contain an uppercase letter');
  } else {
    passwordInput.setCustomValidity(''); // empty string = valid
  }
});
Common Trap

You must call setCustomValidity('') (empty string) to clear the error. If you set a custom validity message and never clear it, the input will remain invalid forever, even if the user fixes the value. This is the most common mistake with custom validation.

Input Events

Understanding which events fire when is crucial for responsive form UIs.

The Event Timeline

const input = document.querySelector('input');

input.addEventListener('focus', () => {
  console.log('focus: input received focus');
});

input.addEventListener('input', (e) => {
  console.log('input: value changed to', e.target.value);
});

input.addEventListener('change', (e) => {
  console.log('change: value committed', e.target.value);
});

input.addEventListener('blur', () => {
  console.log('blur: input lost focus');
});

The key difference between input and change:

  • input fires on every keystroke, paste, or any value change. Use it for live search, character counters, real-time validation.
  • change fires when the user commits the value — usually when the input loses focus (blur) or when Enter is pressed. Use it for "save when done" patterns.

For checkboxes and radio buttons, change fires immediately on click (no blur required).

Form-Level Events

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

form.addEventListener('submit', (e) => {
  e.preventDefault();
  // Handle submission
});

form.addEventListener('reset', () => {
  // Fires when form is reset
});

// The invalid event fires on each invalid input during validation
form.addEventListener('invalid', (e) => {
  e.preventDefault(); // prevents native browser error bubble
  // Show custom error UI instead
}, true); // capture phase — fires before individual input handlers

Putting It Together: A Validated Form

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

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

  if (!form.checkValidity()) {
    form.reportValidity();
    return;
  }

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

  fetch('/api/register', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload)
  });
});

// Live validation feedback on each input
form.querySelectorAll('input').forEach(input => {
  input.addEventListener('input', () => {
    if (input.validity.valid) {
      input.classList.remove('error');
      input.classList.add('valid');
    } else {
      input.classList.remove('valid');
      input.classList.add('error');
    }
  });
});
Quiz
You call setCustomValidity('Too short') on an input, the user fixes the value, but the input still shows as invalid. What went wrong?
Key Rules
  1. 1FormData collects only named, enabled inputs — always add name attributes
  2. 2Use checkValidity for silent validation, reportValidity to show native error bubbles
  3. 3Always clear custom validity with setCustomValidity('') when the input becomes valid
  4. 4input event fires on every keystroke; change fires on commit (blur or Enter)
  5. 5Object.fromEntries(new FormData(form)) is the fastest way to get form data as an object
What developers doWhat they should do
Forgetting to call e.preventDefault() on form submit
Without preventDefault, the form submits normally (full page navigation). If you're handling the submission with JavaScript, you need to cancel the default browser behavior first
Always preventing default before handling submission in JavaScript
Using the input event for checkbox and radio validation
Checkboxes and radios don't fire the input event in all browsers. Use change instead — it fires immediately when the user toggles a checkbox or selects a radio button
Using the change event for checkboxes and radios
Never clearing setCustomValidity after setting it
A non-empty custom validity message permanently marks the input as invalid until explicitly cleared. The browser doesn't auto-clear it when the value changes
Calling setCustomValidity('') when the input becomes valid