Home HTML

HTML <form> element and submission semantics

<form> wraps a set of controls and defines how they are submitted. Knowing the difference between native validation, the FormData API, and constraint validation prevents most form bugs.

What <form> does

<form> groups input controls and gives them a submission target. The element accepts:

  • action — the URL to submit to.
  • methodget (default) or post. Note that post is required for multipart/form-data.
  • enctypeapplication/x-www-form-urlencoded (default), multipart/form-data (file uploads), or text/plain (rare).
  • target — same as <a target>; rarely useful in modern UIs.
  • novalidate — opt out of native constraint validation.

A submit-type button or <input type="submit"> inside the form triggers submission. The Enter key in any single-line text input also triggers submission unless preventDefault is called.

The submission lifecycle

When a submit attempt occurs, the user agent fires events in this order:

  1. formdata on the form — the new event, where authors can mutate the FormData before the request is sent.
  2. Constraint validation — built-in checks against required, pattern, min, max, step, minlength, maxlength, etc.
  3. invalid on each invalid control, then submit on the form. If submit is preventDefault()-ed, the request is suppressed.
  4. Network submission if not prevented.

Calling form.requestSubmit() exercises this full pipeline; calling form.submit() skips validation and submit events entirely. Use requestSubmit unless you have a specific reason.

Native constraint validation

The constraint API exposes:

  • input.validity — a ValidityState with eight boolean properties.
  • input.validationMessage — a localised error string from the UA.
  • input.setCustomValidity(msg) — set a custom message; call with "" to clear.
  • input.checkValidity() and input.reportValidity() — programmatic triggers; the latter shows the UA bubble.

The UA-supplied message is localised to the user’s language. Custom messages override that localisation, so prefer the native message when possible and only set custom for app-specific rules.

FormData and serialisation

new FormData(form) produces a FormData instance containing the current values of every named control inside the form. Use this to build a Fetch request body:

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const data = new FormData(e.currentTarget);
  const res = await fetch(form.action, {
    method: form.method.toUpperCase(),
    body: data,
  });
  // ...
});

FormData correctly handles file uploads, multi-value selects, and checkbox arrays. Avoid round-tripping through plain objects unless you also reimplement those semantics.

Accessibility

Every control needs an accessible name. The order of precedence for form controls is:

  1. <label for="..."> referencing the control by id (preferred).
  2. A wrapping <label>.
  3. aria-labelledby.
  4. aria-label.
  5. placeholder (last resort, not equivalent to a label).

Group related controls with <fieldset> and <legend> — the <legend> is announced as a prefix to each control inside.

Browser engine support

Constraint validation, FormData, the formdata event, and requestSubmit reached cross-engine support across Chromium (Blink), WebKit, and Gecko by early 2022. The inert attribute on form elements (used to disable a fieldset under load) reached interop in 2023.

Common pitfalls

  • form.submit() instead of form.requestSubmit(). Bypasses validation and confuses users when a hidden constraint fails on the server.
  • <button> without type inside a form. Defaults to submit, not button. Add type="button" for non-submitting buttons.
  • Custom error messages without setCustomValidity('') reset. The error sticks across attempts; users see stale text.
  • Async validation in the submit handler. UA validation runs synchronously before the event; an async backend check should happen after submit (with preventDefault) and surface results via setCustomValidity and reportValidity.

Further reading