Mert Özen Further With Every Line
Frontend JavaScript Intermediate — #14

Advanced JavaScript Forms: FormData, the Constraint Validation API and Fetch

Mert Özen Nov 3, 2026 9 min 6 views
Advanced JavaScript Forms: FormData, the Constraint Validation API and Fetch

We collect values with form.elements and FormData, write our own rules with validity and setCustomValidity, catch the invalid event, and send the form with fetch without reloading the page.

Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).

Predict First

This code runs straight in the console, no form on the page needed. It creates an email input, gives it an invalid value, then adds a custom error message and fixes the value:

const input = document.createElement("input");
input.type = "email";
input.required = true;

input.value = "ada";
console.log(input.validity.valid, input.validity.typeMismatch);

input.setCustomValidity("This address is not accepted");
input.value = "ada@example.com";
console.log(input.validity.valid);

What does it print? Make a guess before you read on. Pay special attention to the last line: the value is now a valid email address. The answer is in the section where we look at the browser's validation machinery from the inside.

form.elements: Opening the Form Like a Cabinet

When we worked with forms earlier, we found each field one by one with querySelector and read its value or checked. That gets tiring as the number of fields grows. The form element itself hands you a ready-made cabinet: form.elements. Every control inside sits in its own drawer, labelled by its name attribute.

<form id="signup">
  <input name="email" type="email">
  <input type="radio" name="plan" value="free" checked> Free
  <input type="radio" name="plan" value="pro"> Pro
</form>
<script>
  const form = document.querySelector("#signup");
  console.log(form.elements.email.value);   // ""
  console.log(form.elements.plan.value);    // "free"
  console.log(form.elements.length);        // 3
</script>

Radio buttons that share a name give you a nice surprise here: form.elements.plan returns not a single element but a RadioNodeList, and its value is simply the value of the checked one. No loop needed to find which one is selected. One warning: if you use a name that collides with a form property, like submit, action or reset, you shadow that property; form.submit is now an input, not a method.

FormData: Taking a Snapshot of the Form

When you want all the values at once, FormData steps in. new FormData(form) takes a snapshot of the form as it is right now. If the user changes a field after the snapshot, the snapshot doesn't change; you need to take a new one.

<form id="order">
  <input name="name" value="Ada">
  <input type="checkbox" name="extra" value="sauce" checked>
  <input type="checkbox" name="extra" value="cheese" checked>
  <input name="coupon" value="DISCOUNT" disabled>
  <input value="field without a name">
</form>
<script>
  const data = new FormData(document.querySelector("#order"));

  console.log(data.get("name"));        // "Ada"
  console.log(data.get("extra"));       // "sauce" (only the first)
  console.log(data.getAll("extra"));    // ["sauce", "cheese"]
  console.log(data.has("coupon"));      // false

  for (const [key, value] of data) {
    console.log(key, value);
  }

  data.set("name", "Ada Lovelace");      // replaces if present
  data.append("source", "web");         // adds a new one
  console.log(Object.fromEntries(data)); // { name: "Ada Lovelace", extra: "cheese", source: "web" }
</script>

Several form rules show up in this one example. A field without a name doesn't make it into the snapshot. Neither does a disabled field; that's useful when you want to show a value without sending it, and if you want it sent but not editable, use readonly. An unchecked checkbox doesn't appear at all, so you never get a "false" value; the field's absence is the answer. When several values share a name, get gives you the first and getAll gives you all of them.

Object.fromEntries(data) is very handy, but with repeated names the last one wins; notice that we lost the "sauce" value of extra. If there's an <input type="file"> with a selected file, get gives you a File object; we'll go into working with files in a later post.

The Validator Inside the Browser

Earlier we let the browser do validation with attributes like required, minlength and type="email". Behind those attributes is a mechanism you can also talk to from JavaScript: the Constraint Validation API. Every form control has a validity property, and that object tells you exactly which rule the control is breaking.

Think of it as an exam paper. validity puts a separate mark next to each question: valueMissing (a required field is empty), typeMismatch (wrong email or URL format), patternMismatch (doesn't match the pattern regex), tooShort and tooLong (length), rangeUnderflow and rangeOverflow (min/max), stepMismatch (doesn't fit the step), badInput (the browser couldn't parse the value) and customError (an error you set). At the bottom is the overall grade: valid. If every mark is clear, valid is true.

On top of that, every control has these methods and properties. checkValidity() returns true or false; when it returns false, it also fires an invalid event on the control. reportValidity() does the same check and shows the problem to the user in the browser's own bubble. validationMessage is the error text the browser generates in the user's language. The form element has these methods too: form.checkValidity() checks every field at once.

Now back to the opening question. The first line prints false true: "ada" isn't in email format. The second line prints false. The value is fixed, but the error you set with setCustomValidity is still there; the customError mark stays until you clear it. The browser keeps your rule separate from its own rules and has no way of knowing when to lift it.

Your Own Rule with setCustomValidity

For rules the built-in attributes can't express, there's setCustomValidity(message). The moment you pass a non-empty message, the field counts as invalid, the form won't submit, and the browser shows your message in its bubble. The only way to make the field valid again is to pass an empty string: setCustomValidity(""). The classic example is the "repeat password" field:

const password = form.elements.password;
const confirmField = form.elements.confirm;

function checkMatch() {
  if (confirmField.value !== password.value) {
    confirmField.setCustomValidity("Passwords don't match");
  } else {
    confirmField.setCustomValidity("");
  }
}

password.addEventListener("input", checkMatch);
confirmField.addEventListener("input", checkMatch);

Notice that we hooked the check to the input event of both fields. Had we attached it only to confirmField, the old error, or the old "valid" state, would stick around when the user later changed the first password.

The input, change and invalid Events

Three events come up a lot with form fields. input fires every time the value changes: every keystroke, every paste. change fires when the change is "committed": for a text field when the user leaves it, and for checkboxes, radios and select right when a choice is made. Use input for live search or a character counter, and change for "the field is done, check it now" kinds of jobs.

invalid fires when a field fails validation, that is, when checkValidity() or reportValidity() is called or when the form tries to submit. There's a catch: invalid doesn't bubble. An ordinary listener on the form won't hear the invalid events coming from its fields. This is exactly where the capturing phase we saw earlier comes in handy: attach the listener to the form with { capture: true } and you catch the event on its way down, so one listener covers every field.

If you want to show your own error messages instead of the browser's bubbles, add the novalidate attribute to the form. It turns off the automatic check and the bubbles on submit, but validity and checkValidity() keep working. The browser still enforces the rules; you just display the result. On the CSS side, the :user-invalid selector marks a field only after the user has interacted with it, a much gentler experience than :invalid, which paints empty required fields red the moment the page opens.

All Together: A Form That Validates and Sends with Fetch

Let's put the pieces together. Save the file below and open it in your browser. The form uses the browser's rules, shows errors next to their fields, and when everything is valid, sends the data without reloading the page. As the target we use a public test service that echoes the incoming request back.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Sign-up form</title>
  <style>
    .error { color: crimson; font-size: 0.9em; }
    input:user-invalid { border-color: crimson; }
  </style>
</head>
<body>
  <form id="signup" novalidate>
    <p><input name="email" type="email" required placeholder="Email">
       <span class="error" data-for="email"></span></p>
    <p><input name="password" type="password" required minlength="8" placeholder="Password">
       <span class="error" data-for="password"></span></p>
    <p><input name="confirm" type="password" required placeholder="Repeat password">
       <span class="error" data-for="confirm"></span></p>
    <button>Sign up</button>
    <p id="status"></p>
  </form>
  <script>
    const form = document.querySelector("#signup");
    const password = form.elements.password;
    const confirmField = form.elements.confirm;
    const status = document.querySelector("#status");

    function showError(field) {
      const slot = form.querySelector(`[data-for="${field.name}"]`);
      if (slot) slot.textContent = field.validationMessage;
    }

    function checkMatch() {
      confirmField.setCustomValidity(confirmField.value === password.value ? "" : "Passwords don't match");
    }

    password.addEventListener("input", checkMatch);
    confirmField.addEventListener("input", checkMatch);

    // invalid doesn't bubble, so one capturing listener is enough
    form.addEventListener("invalid", (event) => showError(event.target), { capture: true });
    // Refresh the message of fields showing an error as the user types
    form.addEventListener("input", () => {
      for (const field of form.elements) {
        const slot = form.querySelector(`[data-for="${field.name}"]`);
        if (slot?.textContent) showError(field);
      }
    });

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      checkMatch();
      if (!form.checkValidity()) return;

      const button = form.querySelector("button");
      button.disabled = true;
      status.textContent = "Sending...";

      try {
        const response = await fetch("https://httpbin.org/post", {
          method: "POST",
          body: new FormData(form)
        });
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const result = await response.json();
        console.log("Fields that reached the server:", result.form);
        status.textContent = "Sign-up received.";
        form.reset();
      } catch (error) {
        status.textContent = `Could not send: ${error.message}`;
      } finally {
        button.disabled = false;
      }
    });
  </script>
</body>
</html>

Let's read the flow step by step. On submit we first stop the default submission, then call form.checkValidity(). That call fires an invalid event for every invalid field, and the listener we attached with capture writes each one's validationMessage into its own span. Whenever the user types into any field, the input listener on the form refreshes the message of every field currently showing an error. If a field is fixed, its message disappears on its own, because a valid field's validationMessage is an empty string. We look at the whole form rather than a single field for a reason: changing the password can make the repeat field valid or invalid too. Writing the error text with textContent is deliberate as well: it keeps the XSS risk we saw earlier at zero.

Three Ways to Send a Form with Fetch

If you pass a FormData directly as the body of the fetch we saw earlier, the browser encodes the request as multipart/form-data. That's the format that can carry file uploads too. There's a very common mistake here: don't write the Content-Type header by hand. The browser adds a boundary value to that header to separate the parts; set it yourself and the boundary is lost, and the server can't decode the request.

const data = new FormData(form);

// 1. multipart/form-data (files included)
fetch(url, { method: "POST", body: data });

// 2. application/x-www-form-urlencoded (classic form format, no files)
fetch(url, { method: "POST", body: new URLSearchParams(data) });

// 3. JSON (when the API expects JSON)
fetch(url, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(Object.fromEntries(data))
});

Which one you pick depends on what the server expects. If you go the JSON route, remember that Object.fromEntries overwrites repeated names; if you have multiple checkboxes, add that field separately with getAll. And if you need to submit a form from code, use form.requestSubmit() rather than form.submit(). submit() skips validation and never fires the submit event, so it bypasses all the logic you just wrote. requestSubmit() behaves as if the user had clicked the button.

Common Pitfalls

Forgetting to clear setCustomValidity. The user fixes the problem, the field still looks invalid, and the form never submits. Wherever you set a rule, make sure you pass "" once the rule is satisfied.

A field without a name. It shows on screen, the user fills it in, but it never makes it into FormData. You usually notice the missing data on the server last.

Listening for invalid on the form without capture. The listener never runs, and you get no error either.

Leaving validation to the browser alone. Client-side validation is for user experience, not security. Anyone who opens DevTools can add novalidate or send the request by hand; the server must check the same rules.

A Small Experiment

★☆☆ Add <input name="age" type="number" min="18" max="120"> to the example form. Try typing 15, then 200, and each time check in the console which mark of form.elements.age.validity is true.

★★☆ Predict the output: add <input type="checkbox" name="terms"> to the same form and run new FormData(form).get("terms") without checking it. Then check it and try again. What are the two results, and why?

★★★ For a username field, write a fake "is this name taken?" check on the change event: if the user enters "admin" or "root", raise an error with setCustomValidity. Then move it to the input event and compare how the two approaches feel to the user.

What happens if the user fills half of a long form and accidentally closes the tab? Right now, everything is lost. In the next post we look at the small stores the browser gives you: localStorage, sessionStorage and cookies, and we'll talk about what you can keep where and what you should never keep at all.