FormData JavaScript: Forms, Files, and Fetch
A registration form can contain text, several selected interests, two uploaded files, an unchecked option and more than one submit button, but only some of those controls become request fields.
FormData is an ordered list of string and File entries that JavaScript can build from a form and send with fetch as a multipart request without manually encoding file bytes or boundaries.
What FormData is and when to use it
FormData represents the fields of a form as an ordered entry list. Every entry has a string name and a value that is either a string or a File.
An entry list differs from a plain object because names can repeat. A registration form can contain three checked boxes named topics, and FormData keeps three separate topics entries in their original order.
That model fits files too. A file input contributes a File, while a text input contributes a string. The FormData reference covers the API on its own; this article follows one form all the way into a request.
The expected media type matters. FormData prepares a payload, but it does not choose the server endpoint, field names, validation rules, upload limits, storage policy or multipart parser.
FormData models a submitted form, not the entire form element. Controls must pass the browser’s submission rules before they enter the list.
Create FormData from a form
Start with one registration form containing text, repeated checkboxes, a multiple-file input and two named submit buttons:
<form id="registration-form">
<label>
Display name
<input name="displayName" value="Maya">
</label>
<label>
Email
<input type="email" value="[email protected]">
</label>
<label>
Team code
<input name="teamCode" value="ALPINE" disabled>
</label>
<fieldset>
<legend>Topics</legend>
<label>
<input type="checkbox" name="topics" value="javascript" checked>
JavaScript
</label>
<label>
<input type="checkbox" name="topics" value="css">
CSS
</label>
<label>
<input type="checkbox" name="topics" value="performance" checked>
Performance
</label>
</fieldset>
<label>
Attendance
<select name="attendance">
<option value="remote" selected>Remote</option>
<option value="office">Office</option>
</select>
</label>
<label>
Work samples
<input type="file" name="samples" multiple>
</label>
<button type="submit" name="action" value="preview">Preview</button>
<button type="submit" name="action" value="register">Register</button>
</form>
The controls look like one group on the page, but the browser tests each one separately. These are the successful-controls rules that determine the entry list:
| Form control | Included? | Reason |
|---|---|---|
displayName text input | Yes | It has a nonempty name and is enabled |
| Email input | No | It has no name |
teamCode input | No | It is disabled |
Checked topics boxes | Yes | Each checked box contributes one entry |
Unchecked topics box | No | Unchecked checkboxes are excluded |
Selected remote option | Yes | A selected, enabled option contributes an entry |
Unselected office option | No | Only selected, enabled options contribute entries |
Chosen samples files | Yes | Each selected file contributes an entry |
preview button | Sometimes | It is included only when it is the designated submitter |
register button | Sometimes | It is included only when it is the designated submitter |
Radio buttons follow the checkbox rule: an unchecked radio button contributes nothing. A select can contribute more than one entry when it allows multiple selections, because every selected and enabled option uses the select element’s name.
The submit event tells you which button initiated submission through event.submitter. Pass that button as the optional second constructor argument:
const form = document.querySelector('#registration-form');
form.addEventListener('submit', (event) => {
event.preventDefault();
const data = new FormData(form, event.submitter);
for (const [name, value] of data) {
console.log(name, value);
}
});
If the reader presses Register after selecting portfolio.pdf and profile.png, the list contains displayName, two topics entries, attendance, two samples entries and action=register. The unnamed email, disabled team code, unchecked CSS box and Preview button stay out.
Without the second argument, neither submit button contributes its name and value. That detail matters when two buttons send the same form to different actions. Form submission events and methods explains the surrounding submission flow, while form properties and methods covers how JavaScript reaches the controls.
Read and change FormData entries
A FormData object keeps duplicate names as distinct entries. That rule connects append, set, get and getAll.
Use append to add one more value:
const data = new FormData();
data.append('topics', 'javascript');
data.append('topics', 'performance');
data.append('guests', 2);
The list now has two topics entries followed by guests. The number 2 becomes the string "2" immediately, because values that are neither strings nor Blob objects are converted to strings.
get reads the first matching value, while getAll reads every matching value:
const data = new FormData();
data.append('topics', 'javascript');
data.append('topics', 'performance');
console.log(data.get('topics'));
console.log(data.getAll('topics').join(', '));
console.log(data.getAll('missing').length);
javascript
javascript, performance
0
getAll returns an empty array when the name is absent. That makes it the correct read for checkbox groups, multiple selects and multiple-file fields.
Now replace the repeated values:
const data = new FormData();
data.append('topics', 'javascript');
data.append('topics', 'performance');
data.set('topics', 'forms');
console.log(data.getAll('topics').join(', '));
forms
set removes all existing topics entries and installs one replacement. append preserves the list; set rewrites that name.
The remaining methods inspect or remove entries:
has(name)reports whether at least one matching entry exists.delete(name)removes every entry with that name.entries()iterates over[name, value]pairs.keys()iterates over names, including repeated names.values()iterates over string andFilevalues.for...ofover FormData produces the same pairs asentries().
For a quick inspection, read both the type and value:
for (const [name, value] of data) {
if (value instanceof File) {
console.log(name, value.name, value.size, value.type);
} else {
console.log(name, value);
}
}
A printed entry list is the first useful checkpoint when a field disappears. It separates form construction bugs from request and server bugs.
Send text and files with fetch
The canonical implementation uses the form unchanged, keeps its repeated entries, adds a generated text report as a Blob, and sends everything with fetch:
const form = document.querySelector('#registration-form');
const status = document.createElement('p');
status.setAttribute('role', 'status');
form.insertAdjacentElement('afterend', status);
form.addEventListener('submit', async (event) => {
event.preventDefault();
status.textContent = 'Sending registration…';
const data = new FormData(form, event.submitter);
const report = new Blob(
[
`Display name: ${data.get('displayName')}\n`,
`Topics: ${data.getAll('topics').join(', ')}\n`,
`Attendance: ${data.get('attendance')}\n`,
],
{ type: 'text/plain' }
);
data.append('summary', report, 'registration-summary.txt');
try {
const response = await fetch('/registrations', {
method: 'POST',
body: data,
});
if (!response.ok) {
throw new Error(`Registration failed with HTTP ${response.status}`);
}
const result = await response.json();
status.textContent = `Registration ${result.id} saved.`;
} catch (error) {
status.textContent = error.message;
}
});
The file input supplies File objects under samples. The script adds a Blob under summary and supplies the filename registration-summary.txt.
That third argument matters for a Blob. Without it, the reported filename defaults to "blob". A File uses its original filename by default, although append can supply a replacement filename for either value.
Do not set the Content-Type header in this fetch call.
When body is FormData, the browser encodes the entries as multipart/form-data and generates a boundary. It also puts that boundary in the Content-Type header. A manually written header such as multipart/form-data omits the matching boundary and can leave the server unable to split the body into parts.
The worked request has a direct trace. Suppose the user chooses example.txt and diagram.png, then activates the register button. The ordered entry list immediately before fetch is:
displayName = "Maya"
topics = "javascript"
topics = "performance"
attendance = "remote"
samples = File("example.txt")
samples = File("diagram.png")
action = "register"
summary = File("registration-summary.txt")
The browser generates the actual boundary and wire bytes. The following boundary and file bodies are illustrative, but they show the corresponding request structure; each line break on the wire is CRLF:
Content-Type: multipart/form-data; boundary=example-boundary-7MA4YWxk
--example-boundary-7MA4YWxk
Content-Disposition: form-data; name="displayName"
Maya
--example-boundary-7MA4YWxk
Content-Disposition: form-data; name="topics"
javascript
--example-boundary-7MA4YWxk
Content-Disposition: form-data; name="topics"
performance
--example-boundary-7MA4YWxk
Content-Disposition: form-data; name="attendance"
remote
--example-boundary-7MA4YWxk
Content-Disposition: form-data; name="samples"; filename="example.txt"
Content-Type: text/plain
<bytes of example.txt>
--example-boundary-7MA4YWxk
Content-Disposition: form-data; name="samples"; filename="diagram.png"
Content-Type: image/png
<bytes of diagram.png>
--example-boundary-7MA4YWxk
Content-Disposition: form-data; name="action"
register
--example-boundary-7MA4YWxk
Content-Disposition: form-data; name="summary"; filename="registration-summary.txt"
Content-Type: text/plain
<bytes of registration-summary.txt>
--example-boundary-7MA4YWxk--
A multipart parser might expose the result like this, although the exact server API varies:
fields.displayName = "Maya"
fields.topics = ["javascript", "performance"]
fields.attendance = "remote"
fields.action = "register"
files.samples = [File("example.txt"), File("diagram.png")]
files.summary = File("registration-summary.txt")
The repeated topics parts remain separate, and the generated boundary in the header matches every delimiter in the encoded body.
Open the browser Network panel and inspect the request headers and submitted fields when this chain breaks. Redact real filenames and personal data before sharing a screenshot, and never copy a boundary from another request.
The response check belongs in the same implementation. fetch can fulfill its promise when the server returns an HTTP error such as 404, so response.ok must be checked explicitly. Fetch covers the request API in more detail, and File and FileReader covers reading selected files in the browser.
For a wider treatment of promises, modules and request code, Async, Modules & Modern JavaScript carries the same flow into the surrounding language features.
Convert FormData without losing values
Object.fromEntries looks like a natural conversion because FormData iterates over name and value pairs:
const data = new FormData();
data.append('displayName', 'Maya');
data.append('topics', 'javascript');
data.append('topics', 'performance');
const object = Object.fromEntries(data);
console.log(object.displayName);
console.log(object.topics);
Maya
performance
The object has one property named topics, so the later entry replaces the earlier one. This conversion is suitable only when the form is known to use unique names.
A duplicate-preserving reducer must decide whether each property holds one value or an array. This standalone alternative keeps every repeated value:
function formDataToObject(data) {
const result = Object.create(null);
for (const [name, value] of data) {
if (!Object.hasOwn(result, name)) {
result[name] = value;
continue;
}
if (!Array.isArray(result[name])) {
result[name] = [result[name]];
}
result[name].push(value);
}
return result;
}
const data = new FormData();
data.append('displayName', 'Maya');
data.append('topics', 'javascript');
data.append('topics', 'performance');
data.append('__proto__', 'first');
data.append('__proto__', 'second');
const object = formDataToObject(data);
console.log(object.displayName);
console.log(object.topics.join(', '));
console.log(object.__proto__.join(', '));
Maya
javascript, performance
first, second
That preserves duplicates, but files need another decision. The resulting object can still contain File objects, and turning that object into JSON does not turn file bytes into useful JSON data.
Choose a file policy before calling JSON.stringify. The application might reject files for that conversion, upload them separately and store returned identifiers, or extract selected metadata such as the filename and type. FormData itself does not choose among those jobs.
URLSearchParams has the same repeated-name shape for strings, and append can preserve repeated string values. It cannot preserve uploaded file bytes as files. Convert to it only when every entry is a string and the server expects URL-encoded data.
This is the conversion rule:
- Use
Object.fromEntries(data)only for known unique names and an accepted file policy. - Use a reducer when repeated names must become arrays.
The strings guide covers the conversions that happen once a value must become text.
Debug the FormData bugs that waste time
Start with the symptom, then inspect the earliest stage that can produce it.
A missing text field usually begins in the form. Check for a nonempty name, then check whether the control is disabled. An id labels a DOM element for JavaScript and CSS, but the FormData entry uses name.
A missing checkbox or radio value has another first check: is the control checked? Unchecked controls are absent, so FormData does not contain a string such as "false" unless the application adds one itself.
A missing submit action points to construction. Use new FormData(form, event.submitter) when the endpoint needs the initiating button’s name and value.
A checkbox group reduced to one value points to reading or conversion. get('topics') returns the first entry, set('topics', value) replaces the group, and Object.fromEntries(data) collapses repeated names into one object property. Inspect getAll('topics') before conversion.
Missing files can begin at the input or at the server. First inspect the file input’s files.length. Then inspect the FormData entry and confirm that it is a File with the expected filename. An unselected successful file input can contribute an empty File with an empty filename and body, so the type check alone is insufficient; a selected file can also legitimately contain zero bytes. Finally, inspect the Network panel and confirm that the server endpoint accepts multipart/form-data and has a multipart parser configured for the expected field names.
A malformed multipart request often has an explicitly written Content-Type header. Remove that header and let fetch generate both the boundary and the matching encoded body.
An empty server body does not prove that the browser sent nothing. First inspect the Network panel. If the request contains the expected multipart fields, the remaining work is at the endpoint or parser rather than in FormData.
Finally, distinguish transport failure from HTTP failure. A caught network error and an HTTP 422 are not the same result. Check response.ok, record response.status, and read the response body according to the endpoint’s documented format.
For requests that cross origins, the server also has to permit the request under the browser’s cross-origin rules. Fetch: Cross-Origin Requests covers that separate layer.
FormData method and decision reference
The methods all operate on the same ordered entry list:
| API | Result |
|---|---|
append(name, value, filename?) | Adds another entry at the end |
set(name, value, filename?) | Replaces all entries with that name |
get(name) | Returns the first value or null |
getAll(name) | Returns every value or an empty array |
has(name) | Reports whether the name exists |
delete(name) | Removes every entry with that name |
entries() | Iterates over name and value pairs |
keys() | Iterates over names |
values() | Iterates over values |
for...of | Iterates over the same pairs as entries() |
Choose FormData when files or repeated fields must reach an endpoint expecting multipart data. Choose JSON when the endpoint expects JSON and the application has decided how every value, especially every file, becomes JSON data. Choose URLSearchParams when all values are strings and the endpoint expects URL-encoded fields.
Also consider the data shape and value types: JSON directly represents nested objects, arrays, numbers, booleans and null, while FormData and URLSearchParams need an explicit flattening or stringification convention. Parameters that identify or filter a resource may belong in the URL query rather than the request body.