Skip to main content

πŸ“¦ 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 FormData objects 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.
flowchart LR A[HTML Form] --> B[FormData object] C[JS values] --> B D[File inputs] --> B B --> E[fetch] E --> F[Server]

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.

MethodWhat 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.fromEntries keeps 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:

  1. A <input type="file" accept="image/*">, a preview <img>, a <progress>, and a submit button.
  2. On change, reject non-images and files over 2 MB with a message; otherwise show a preview using FileReader (or URL.createObjectURL).
  3. On submit, build a FormData and upload it with the uploadWithProgress helper, driving the progress bar.
  4. 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-Type for multipart FormData β€” never set it yourself.
  • Give every control a name so 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.createObjectURL and 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.
  • append keeps existing values (multi-value keys); set replaces them. Use getAll() for groups.
  • Send it as a fetch body with no Content-Type header; use XMLHttpRequest when you need upload progress.
  • Convert to JSON with Object.fromEntries for 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.