Submitting Forms with JavaScript and formdata

Hero image for Submitting Forms with JavaScript and formdata. Image by Zooey Li.
Hero image for 'Submitting Forms with JavaScript and formdata.' Image by Zooey Li.

In Brief

Construct FormData from the form and pass it to Fetch as the request body; let the browser generate the multipart boundary. Serverside validation remains authoritative, and Fetch does not reject its promise merely because the server returns an unsuccessful HTTP status. Keep a working native form action so JavaScript remains an enhancement.

It is easy to turn a form into a JavaScript object by copying the few fields visible in a design. It is also easy for that object to stop representing the form.

Add two checked boxes with the same name, an empty field, or a file input and the gaps appear. The browser already knows how to construct a form data set. FormData lets us use that work instead of quietly inventing a different submission format.


Why a Plain Object is Not the Form

Suppose a contact form allows several interests and an optional attachment. This manual copy keeps only the first checked interest and ignores the file:

const payload = {  name: form.elements.name.value,  interest: form.querySelector('[name="interest"]:checked').value};

More copying can patch that particular example, but it leaves our JavaScript responsible for rules the browser already applies. A form is not merely a set of text inputs. Controls have field names and states; some contribute values and some do not. Files are not ordinary strings, and repeated field names are valid.

That plain object may be exactly what an endpoint expecting JSON needs. It is not, however, a faithful serialisation of an ordinary form by default. The distinction should be deliberate.

There are three useful paths to compare:

Submission pathWho constructs the form data set?Main tradeoff
Native form submissionThe browserNavigates in the normal way and works without JavaScript
Manually created objectApplication codeSuits an endpoint with a deliberately different contract, but every included value becomes our responsibility
FormData constructed from the formThe browser's form rules, exposed to JavaScriptPreserves the native entries whilst allowing an asynchronous request

The third path is useful when the server already accepts an ordinary form body and the page needs to update without navigation. It should not be used merely to make a simple form look more sophisticated.


Constructing Formdata from the Form

Start with a form that works without JavaScript:

<form action="/contact" method="post" enctype="multipart/form-data"  data-contact-form>  <input type="hidden" name="form_token" value="SERVER_GENERATED_VALUE">  <label for="name">Name</label>  <input id="name" name="name" required>  <label for="email">Email address</label>  <input id="email" name="email" type="email">  <fieldset>    <legend>Interests</legend>    <label><input type="checkbox" name="interest" value="css"> CSS</label>    <label><input type="checkbox" name="interest" value="javascript"> JavaScript</label>  </fieldset>  <label for="attachment">Attachment</label>  <input id="attachment" name="attachment" type="file">  <button type="submit">Send message</button>  <p data-form-status role="status" aria-live="polite"></p></form>

The action and method give the browser a usable native submission path. The hidden token stands for whatever protection the server already supplies; it must be rendered by the real application, not copied from this example.

Constructing the body is the small part:

const formData = new FormData(form);

The browser includes named successful controls. In this example, checked interest boxes contribute values, an unchecked box does not, and a disabled control would not be included. An empty enabled text control still contributes its empty value. A selected file contributes a File value. These rules come from HTML's construction of the form data set, rather than from a list maintained separately in our script.

Inspecting Values

Use targeted methods when inspecting or adjusting the result in browsers which provide them:

formData.get('name');formData.getAll('interest');formData.append('source', 'contact-page');formData.set('source', 'article-example');

get() returns the first matching value. getAll() preserves repeated values. append() adds another entry, whereas set() replaces existing entries with that name. Do not use set() when repetition is meaningful.

In August 2018, get(), getAll(), and set() were not yet available across every current Edge and Safari version, so the submission path below does not depend on them. Check the project's browser requirement before using them in application logic. append() and construction from a form had much broader support.


Submitting from the Form Event

Attach the enhanced path only when the browser provides both required APIs:

const form = document.querySelector('[data-contact-form]');const status = form.querySelector('[data-form-status]');const submitButton = form.querySelector('[type="submit"]');if (typeof window.fetch === 'function' && window.FormData) {  form.addEventListener('submit', function (event) {    let body;    try {      body = new FormData(form);    } catch (error) {      return;    }    event.preventDefault();    submitButton.disabled = true;    status.textContent = 'Sending your message…';    fetch(form.action, {      method: form.method.toUpperCase(),      body: body,      credentials: 'same-origin'    })      .then(function (response) {        if (!response.ok) {          const error = new Error('Unsuccessful response');          error.httpStatus = response.status;          throw error;        }        return response;      })      .then(function () {        status.textContent = 'Your message has been sent.';        submitButton.disabled = false;      })      .catch(function (error) {        status.textContent = error.httpStatus          ? 'The server could not accept your message. Please check it and try again.'          : 'The message could not be sent. Check your connection and try again.';        submitButton.disabled = false;      });  });}

For an ordinary user submission, native constraint validation happens before the submit listener runs. The body is constructed before preventDefault(), so a failure at that point leaves the native action available. Once the asynchronous request starts, the button is disabled to prevent an accidental second request and restored for either outcome.

The example needs only the response status, so it does not read a body. If the endpoint returns validation details or a receipt which the page must use, read the documented response format as a separate step. This is form submission, not a general Fetch wrapper.

Do Not Set the Multipart Boundary Yourself

This request deliberately has no Content-Type header:

fetch(form.action, {  method: 'POST',  headers: {    'Content-Type': 'multipart/form-data'  },  body: new FormData(form)});

That version is broken. A multipart content type needs a boundary which also appears between the parts in the request body. When the body is FormData, Fetch's body extraction rules make the browser generate both the encoding and the matching boundary parameter. Supplying a bare header prevents the browser from advertising the boundary it created.

Other application headers may still be necessary, but preserve the server's established contract. Do not replace a named CSRF control or required credential behaviour accidentally whilst changing the transport.


Repeated Fields and Files

Two checked controls named interest produce two parts with the same field name. That is not a collision. The server should read the repeated values according to its form parser. Likewise, the attachment arrives as a file part with its filename and media type where supplied by the browser.

Avoid turning these entries into JSON unless the endpoint specifically defines a separate encoding for files. FormData exists partly so the browser can preserve this mixture of text and binary values.

Use the Network panel to inspect the payload during development. Confirm a text value, both checked interests, an empty value, and a selected file. Also confirm that disabled and unnamed controls are absent.


Handling the Response

Fetch distinguishes network failure from an HTTP response. A failed connection rejects the promise. A server response with status 400, 404, or 500 still fulfils it with a Response, so the code must check response.ok or response.status.

Keep the feedback honest. A success message should appear only after the server has accepted the submission. On failure, reenable the control and provide a retry path. The live status text above supplements the disabled button; final focus and errorsummary behaviour should follow the form's existing validation pattern.


Browser Support in 2018

FormData itself was established across the main browsers, including Internet Explorer 10 and 11. Fetch was available in contemporary Chrome, Firefox, Edge, and Safari, but not Internet Explorer. The capability check therefore matters: without Fetch, this example leaves the form's ordinary action alone.

If a project requires asynchronous submission in a browser without Fetch, use the project's tested Fetch polyfill or an XMLHttpRequest path. That is a separate compatibility decision. A FormData object does not add Fetch support, just as a Fetch polyfill does not decide whether the server accepts multipart data.


Keep the Server in Charge of Validation

FormData serialises controls. It does not decide whether an email address is acceptable, whether a file is safe, or whether a request is authorised. Clientside constraints improve feedback, but every value and file still needs validation on the server.

Preserve the application's CSRF defence, enforce file limits and allowed types at the receiving endpoint, and encode or escape submitted values for the context in which they are later used. The browser describes the request; the server decides whether to trust and process it.


Wrapping Up

Use FormData when JavaScript submission should retain the form's native names, repeated values, empty values, and files. Let the browser serialise the multipart body and boundary, check unsuccessful HTTP statuses explicitly, and leave validation with the server. Most importantly, keep the HTML form useful before the enhancement runs.


Postscript

Aug 2026: This article forms part of an archive restored from a previous version of my website. Its original publication date is accurate. During the restoration, I reviewed and updated it where appropriate for formatting, imagery, broken links, code correctness, and current internal references, whilst preserving the original technical context and intent.

The underlying FormData and Fetch APIs described here remain valid. Modern frameworks often provide higherlevel abstractions around form handling, but the browser primitives themselves continue to underpin those approaches and remain useful to understand.


Planning a platform change?

I help teams make difficult platform work clearer, from architecture decisions and migrations to launch recovery, performance, and search visibility.