π¦ FormData API and Processing
The FormData API is the courier that packages your form β text fields, checkboxes, and files alike β and hands it to fetch for delivery. This lesson covers building FormData objects, working with files and multi-value fields, converting to JSON, and receiving the package on a Node, PHP, or Python backend.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Create
FormDataobjects from a form, empty, or programmatically - Use its methods β
append,set,get,getAll,has,delete, and iteration - Submit FormData with
fetch, including file uploads and multi-value fields - Convert FormData to a plain object or JSON when an API expects it
- Describe how Node/Express, PHP, and Flask receive and process the data safely
Estimated Time: 40β50 minutes β’ Difficulty: Intermediate
Hands-on: Build an image-upload form with a live preview and an upload-progress bar.
In This Lesson
What FormData Is
The FormData interface builds a set of key/value pairs representing form fields and their values, ready to be sent with fetch or XMLHttpRequest. It shines in four situations:
- AJAX submissions β send a form with no page reload.
- File uploads β
<input type="file">values come along automatically. - Dynamic data β assemble fields in code, not just from the DOM.
- Multipart data β mix text and binary files in a single request.
π‘ The courier analogy. FormData is a digital courier service. Instead of mailing a paper form, it neatly boxes up all your fields β including bulky attachments like files β labels the package correctly, and delivers it to the server, handling the fiddly logistics (encoding, boundaries) for you.
Creating FormData Objects
From a form element
The most common route β pass a <form> and every named control is captured automatically, files included:
const form = document.querySelector('#profile-form');
const data = new FormData(form);
await fetch('/api/profile', { method: 'POST', body: data });
π "Named" means it has a name attribute
FormData only picks up controls that have a name. A field with only an id is invisible to it. Also note: disabled and unchecked checkbox/radio fields are excluded β a frequent source of "why is my value missing?" bugs.
Empty, then filled programmatically
const data = new FormData();
data.append('username', 'raydev');
data.append('email', 'ray@example.com');
data.append('joinedAt', new Date().toISOString());
// Attach a file straight from an input
const fileInput = document.querySelector('input[type="file"]');
data.append('avatar', fileInput.files[0]);
This is ideal when your data doesn't map one-to-one to visible form fields, or when you build a request from several user interactions β like an e-commerce configurator that appends each chosen option as the user clicks.
The FormData Methods
A handful of methods cover everything. The one distinction that trips people up is append versus set.
| Method | What it does |
|---|---|
append(k, v) | Adds a pair β keeps any existing value for that key (so a key can hold several values). |
set(k, v) | Sets a pair β replaces all existing values for that key. |
get(k) | Returns the first value for a key. |
getAll(k) | Returns an array of all values for a key (checkbox groups, multi-selects). |
has(k) | Returns true if the key exists. |
delete(k) | Removes all values for a key. |
entries() / keys() / values() | Iterators β a FormData is itself iterable. |
const data = new FormData();
data.append('tag', 'js');
data.append('tag', 'css'); // now 'tag' holds TWO values
data.getAll('tag'); // ['js', 'css']
data.set('tag', 'html'); // replaces both
data.getAll('tag'); // ['html']
// FormData is iterable β loop straight over it
for (const [key, value] of data) {
console.log(`${key} = ${value}`);
}
Multi-value fields work for free
Checkbox groups and <select multiple> that share a name are gathered automatically β no special code required:
<input type="checkbox" name="interests" value="sports">
<input type="checkbox" name="interests" value="music">
<select name="countries" multiple> β¦ </select>
const data = new FormData(form);
data.getAll('interests'); // e.g. ['sports', 'music']
data.getAll('countries'); // e.g. ['us', 'ca']
Submitting with fetch
Sending FormData is a one-liner body. The golden rule bears repeating: do not set a Content-Type header. The browser sets multipart/form-data with the correct boundary string automatically; setting it yourself corrupts the request.
form.addEventListener('submit', async (event) => {
event.preventDefault();
const button = form.querySelector('button[type="submit"]');
button.disabled = true;
button.textContent = 'Submittingβ¦';
try {
const res = await fetch('/api/submit', {
method: 'POST',
body: new FormData(form), // no Content-Type header!
});
if (!res.ok) throw new Error(`Server ${res.status}`);
const result = await res.json();
console.log('Success:', result);
form.reset();
} catch (err) {
console.error('Submit failed:', err);
} finally {
button.disabled = false;
button.textContent = 'Submit';
}
});
Upload progress
fetch still can't report upload progress natively, so for a progress bar the venerable XMLHttpRequest remains the practical choice thanks to its upload.onprogress event:
function uploadWithProgress(formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) onProgress((e.loaded / e.total) * 100);
});
xhr.addEventListener('load', () =>
xhr.status === 200 ? resolve(xhr.responseText) : reject(xhr.statusText)
);
xhr.addEventListener('error', () => reject('Network error'));
xhr.send(formData);
});
}
// Usage
uploadWithProgress(new FormData(form), (percent) => {
progressBar.value = percent;
});
π‘ Cancelling a fetch upload
Even without progress, you can make a fetch upload cancellable by passing an AbortController's signal in the options and calling controller.abort() β handy for a "Cancel upload" button.
Converting FormData to JSON
Some APIs want JSON, not multipart form data. For flat, text-only forms the conversion is a two-line idiom using Object.fromEntries:
const data = new FormData(form);
const json = Object.fromEntries(data.entries());
await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(json),
});
β οΈ Two caveats with the one-liner
Object.fromEntrieskeeps only the last value of a repeated key. For checkbox groups you must handle multi-values yourself.- File objects don't serialize to JSON. If your form has file inputs, send it as multipart FormData instead β JSON is for text data.
To preserve multi-value keys, build the object manually:
function formDataToObject(formData) {
const obj = {};
for (const [key, value] of formData) {
if (key in obj) {
obj[key] = [].concat(obj[key], value); // grow into an array
} else {
obj[key] = value;
}
}
return obj;
}
Processing on the Server
Multipart FormData looks the same on the wire regardless of backend; each ecosystem has its idiomatic way to read it. Here is the same profile-upload endpoint in three stacks.
Node.js + Express (multer)
const express = require('express');
const multer = require('multer');
const app = express();
const upload = multer({ dest: 'uploads/' });
// upload.single() parses one file field; text fields land in req.body
app.post('/api/profile', upload.single('avatar'), (req, res) => {
res.json({
message: 'Saved',
user: req.body, // { username, email }
file: req.file, // { filename, size, mimetype, β¦ }
});
});
app.listen(3000);
PHP
<?php
header('Content-Type: application/json');
$username = $_POST['username'] ?? '';
$email = $_POST['email'] ?? '';
if (isset($_FILES['avatar']) && $_FILES['avatar']['error'] === UPLOAD_ERR_OK) {
$name = basename($_FILES['avatar']['name']);
move_uploaded_file($_FILES['avatar']['tmp_name'], "uploads/$name");
}
echo json_encode(['message' => 'Saved', 'user' => $username]);
Python + Flask
from flask import Flask, request, jsonify
from werkzeug.utils import secure_filename
import os
app = Flask(__name__)
@app.route('/api/profile', methods=['POST'])
def profile():
username = request.form.get('username')
email = request.form.get('email')
file = request.files.get('avatar')
if file and file.filename:
filename = secure_filename(file.filename)
file.save(os.path.join('uploads', filename))
return jsonify(message='Saved', user=username)
β The pattern is universal
Text fields arrive in a "body/form" bag (req.body, $_POST, request.form) and files arrive separately (req.file, $_FILES, request.files). Learn the shape once and every framework feels familiar β the same "different tools, same fundamentals" idea that runs through this whole course.
Hands-on Exercise
ποΈ Image upload with preview & progress
Objective: Build an avatar-upload form that previews the image, validates it, and shows upload progress.
Requirements:
- A
<input type="file" accept="image/*">, a preview<img>, a<progress>, and a submit button. - On
change, reject non-images and files over 2 MB with a message; otherwise show a preview usingFileReader(orURL.createObjectURL). - On submit, build a
FormDataand upload it with theuploadWithProgresshelper, driving the progress bar. - Show a success or error message when it finishes.
π‘ Hint
Validate with file.type.startsWith('image/') and file.size <= 2 * 1024 * 1024. URL.createObjectURL(file) gives an instant preview src β remember to URL.revokeObjectURL() it afterwards to free memory.
β Sample solution (core logic)
const input = document.querySelector('#avatar');
const preview = document.querySelector('#preview');
const bar = document.querySelector('#bar');
const status = document.querySelector('#status');
const form = document.querySelector('#upload-form');
const MAX = 2 * 1024 * 1024; // 2 MB
input.addEventListener('change', () => {
const file = input.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
status.textContent = 'Please choose an image file.';
input.value = '';
return;
}
if (file.size > MAX) {
status.textContent = 'That image is over 2 MB.';
input.value = '';
return;
}
status.textContent = '';
preview.src = URL.createObjectURL(file);
preview.onload = () => URL.revokeObjectURL(preview.src);
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (!input.files[0]) return;
try {
await uploadWithProgress(new FormData(form), (p) => { bar.value = p; });
status.textContent = 'β
Uploaded!';
} catch (err) {
status.textContent = 'β οΈ Upload failed. Try again.';
}
});
Best Practices & Security
β Do
- Let the browser set
Content-Typefor multipart FormData β never set it yourself. - Give every control a
nameso FormData can see it. - Use
getAll()for checkbox groups and multi-selects. - Validate files client-side (type, size) for a fast UX β but re-validate on the server.
- Preview with
URL.createObjectURLand revoke the URL when done.
β οΈ Server-side security is mandatory
- Sanitize file names (e.g.
secure_filename()in Flask) β never trust the client's name. - Enforce size limits to prevent denial-of-service uploads.
- Verify file type by content, not just extension.
- Store uploads outside the web root or on a dedicated service (e.g. S3).
- Use CSRF protection on state-changing endpoints.
Summary & Quiz
π Key Takeaways
new FormData(form)captures every named control automatically β files included, disabled/unchecked excluded.appendkeeps existing values (multi-value keys);setreplaces them. UsegetAll()for groups.- Send it as a
fetchbody with no Content-Type header; useXMLHttpRequestwhen you need upload progress. - Convert to JSON with
Object.fromEntriesfor flat text forms β but files need multipart. - Servers split text (
req.body/$_POST/request.form) from files (req.file/$_FILES/request.files) β and must re-validate everything.
π― Quick Quiz
Question 1: When submitting a FormData object with fetch, which header should you set manually?
Question 2: A checkbox group named interests has three boxes checked. How do you read all three values?
Question 3: Why is XMLHttpRequest still sometimes preferred over fetch for file uploads?
π Further Reading
π What's Next?
You can now collect, package, and send any form. Next we move from sending data to keeping it in the browser: localStorage and sessionStorage for saving drafts, preferences, and session state between visits.