JavaScript Forms and Form Validation: FormData, Constraint API, and Modern Patterns

Link copied
JavaScript Forms and Form Validation: FormData, Constraint API, and Modern Patterns

JavaScript Forms and Form Validation: FormData, Constraint API, and Modern Patterns

Forms are the workhorse of every web app — login, signup, checkout, settings, comments, anything that asks the user for data. Modern browsers ship surprisingly powerful built-in tools for handling them: the Constraint Validation API for validation, FormData for collecting field values, and a small set of patterns that turn an HTML form into a clean JSON payload in three lines of JavaScript.

This lesson covers all of it: collecting input, validating it, intercepting submission, and the modern alternatives to writing per-field validation by hand.

The shape of a modern form #

<form id="signup" novalidate>
  <label>
    Email
    <input type="email" name="email" required>
  </label>
  <label>
    Password
    <input type="password" name="password" required minlength="8">
  </label>
  <button type="submit">Sign up</button>
</form>

Notable details:

  • name attribute on every input — this is the key used in FormData and the submitted body.
  • type="email", required, minlength — declarative validation that the browser already understands.
  • novalidate on the form — turns off the browser's default popup UI when validation fails. You'll handle the UI yourself.
  • type="submit" on the button — triggers submission on click or Enter inside any input.

Using native form semantics matters. Screen readers, password managers, and autofill all rely on them.

Collecting input with FormData #

The modern way to read every value from a form is FormData:

const form = document.getElementById('signup');

form.addEventListener('submit', (e) => {
  e.preventDefault();
  const data = new FormData(form);
  console.log(data.get('email'));    // 'ada@example.com'
  console.log(data.get('password')); // 'hunter2'
});

FormData(form) reads every named input — text, checkbox, radio, select, file input, even multi-select — and returns an object-like collection.

Useful methods:

data.get('email');               // first value for the field
data.getAll('hobbies');          // all values (for checkbox groups)
data.has('email');               // boolean
data.set('email', 'override');   // mutate
for (const [key, value] of data) { ... } // iterate

Converting to a plain object #

For sending as JSON to an API:

const data = Object.fromEntries(new FormData(form));
// { email: '...', password: '...' }

Object.fromEntries works because FormData is iterable as [key, value] pairs. One line, no per-field reads.

Caveat: if a field has multiple values (a checkbox group with the same name), Object.fromEntries only keeps the last one. For those, build the object manually or use data.getAll(name).

Sending as the browser would #

For multipart/form-data (file uploads, etc.), pass the FormData directly to fetch:

fetch('/api/signup', {
  method: 'POST',
  body: new FormData(form),  // browser sets the Content-Type with boundary
});

Don't manually set Content-Typefetch needs to generate the multipart boundary itself.

The Constraint Validation API #

The browser already validates inputs based on HTML attributes (required, min, max, pattern, type="email", etc.). The Constraint Validation API exposes this to JavaScript.

input.validity;           // ValidityState object
input.checkValidity();    // boolean — runs the checks
input.reportValidity();   // boolean + shows the browser's error popup
input.setCustomValidity('Sorry, that name is taken'); // custom error message
form.checkValidity();     // boolean for the whole form

The ValidityState object #

input.validity;
// {
//   valid: false,
//   valueMissing: true,    // required, empty
//   typeMismatch: false,   // type="email" with bad input
//   tooShort: false,
//   tooLong: false,
//   rangeUnderflow: false, rangeOverflow: false,
//   patternMismatch: false,
//   stepMismatch: false,
//   customError: false,    // setCustomValidity was called
//   badInput: false,       // browser couldn't parse the input
// }

Reading the specific reason is more useful than just "invalid":

if (input.validity.valueMissing) {
  showError(input, 'This field is required');
} else if (input.validity.typeMismatch) {
  showError(input, 'Please enter a valid email address');
} else if (input.validity.tooShort) {
  showError(input, `Must be at least ${input.minLength} characters`);
}

Custom validation messages #

setCustomValidity(string) marks the input as invalid with your message. Passing '' clears the custom error.

emailInput.addEventListener('input', async (e) => {
  const taken = await checkEmailTaken(e.target.value);
  e.target.setCustomValidity(taken ? 'This email is already registered' : '');
});

Reporting custom messages is how you do async validation (server-checked uniqueness, captcha results) inside the same constraint API.

A complete signup form handler #

Putting it together:

const form = document.getElementById('signup');
const errors = document.getElementById('errors');

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

  // 1. Browser-side validation
  if (!form.checkValidity()) {
    for (const input of form.elements) {
      if (input.validity && !input.validity.valid) {
        errors.textContent = input.validationMessage;
        input.focus();
        return;
      }
    }
  }

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

  // 3. Send to the server
  try {
    const res = await fetch('/api/signup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });
    if (!res.ok) throw new Error(`Server returned ${res.status}`);
    window.location = '/welcome';
  } catch (err) {
    errors.textContent = err.message;
  }
});

Three clear phases: validate, collect, send. The browser handles the validation rules. FormData handles the collection. Fetch handles the network. You wire them together.

Handling input as the user types #

For live validation feedback (as opposed to validating only on submit):

for (const input of form.querySelectorAll('input')) {
  input.addEventListener('input', () => {
    if (input.validity.valid) {
      input.classList.remove('is-invalid');
      input.classList.add('is-valid');
    } else {
      input.classList.remove('is-valid');
      input.classList.add('is-invalid');
    }
  });
}

The 'input' event fires on every change. For expensive checks (server validation, regex on long strings), throttle it with debounceTime or a simple setTimeout.

Validate on blur, not on input #

A UX rule: validating on every keystroke is jarring ("that's not an email!" while the user is still typing). Better:

  • Highlight green/red only after the user leaves the field ('blur' event)
  • Show error messages only after the first submit attempt
  • Clear errors as soon as the user starts typing again

This is the pattern most modern apps use — Stripe, GitHub, Linear all do it.

Form types worth knowing #

Different <input type="..."> values give you different validation, keyboards on mobile, and UI:

type Behavior
email Validates format. Mobile shows @ keyboard.
tel Numeric keyboard on mobile. No format validation (international tel formats vary).
url Validates format. URL-keyboard on mobile.
number Validates numeric. Spinner UI. min, max, step apply.
date, time, datetime-local Native pickers. Value is an ISO string.
color Native color picker.
search Looks like text but renders a clear button.
file File picker. multiple, accept attributes.
password Hidden input. Use autocomplete="new-password" or current-password.

Use the right type — you get better mobile keyboards, native pickers, and free validation. type="text" for everything is a missed opportunity.

Sending files #

<input type="file" name="avatar" accept="image/*">
const file = form.elements.avatar.files[0];
if (file) {
  console.log(file.name, file.size, file.type);
  // Send with FormData
  const data = new FormData();
  data.append('avatar', file);
  await fetch('/upload', { method: 'POST', body: data });
}

For multiple files (<input multiple>), files is a FileList you can iterate.

We cover Fetch fully in Lesson 7.4.

A summary checklist for forms #

  • Use native <form> and submit events. Not <div> and click handlers.
  • Set name on every input. Without it, FormData ignores the field.
  • Use the right type. Get free validation and the right mobile keyboard.
  • Use required, minlength, pattern instead of writing custom checks in JS.
  • novalidate on the form lets you handle UI yourself while still using checkValidity().
  • FormData(form) + Object.fromEntries to get a JSON payload in one line.
  • preventDefault() in the submit handler — otherwise the browser navigates.
  • Validate on blur, show errors on submit. Friendlier UX than validate-on-keystroke.
  • AbortController for in-flight fetch cancellation when a form is closed.

Forms are one of the rare places where the browser ships features that are still under-used. The Constraint Validation API alone replaces a lot of custom JS people are still writing.

What's next #

Lesson 7.4 covers the Fetch APIfetch, request/response, error handling, AbortController, and the patterns for streaming, retries, and timeouts. With forms producing data and fetch sending it, you have the round-trip covered.

Try it yourself #

The FormData → JSON pattern is the cleanest one-liner in the whole tutorial:

YouGiven a form with name, email, and message fields, write the one-liner that produces:
{ name: 'Ada', email: 'ada@x.com', message: 'hi' }
ClaudeThe one-liner is:

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

FormData(form) iterates all named inputs as [name, value] pairs. Object.fromEntries turns those into an object. Three concepts, one line — and it covers 90% of form submission code in modern apps.

A decade of custom form-collection helpers, replaced by one standard line. That's the kind of native browser API that's worth knowing.

Up next in JavaScript

More from this topic

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email address will not be published. Required fields are marked *