FormData - My 2nd Favourite Web API

7 min read
~1.4K words
18/03/23
22/10/22

Of the hundreds of Web APIs available in browsers and some server-side runtimes, the FormData class is one of my favourites.

tl;dr

  1. Use a <form>.
  2. Add namess to all your inputs - most inputs should have unique names. All inputs with type="checkbox" or type="radio" should have the same name for logical groups, and should have values.
  3. Construct a FormData object with the form as an argument: new FormData(form).
  4. Use the get and getAll methods to access any values you need!

Using it

At first blush, the methods available see kinda useless - why use FormData.get and FormData.set when Maps do the exact same thing, and plain old objects can do basically the same thing with an arguable simpler interface? The real power of FormData comes when used with the <form> element. Take the simple register form below:

Register Form Before FormData
html
<form id="register-form">
  <!-- Email Input -->
  <div>
    <label for="register-email">Email</label>
    <input type="email" id="register-email" required />
  </div>
 
  <!-- Password Input -->
  <div>
    <label for="register-password">Password</label>
    <input type="password" id="register-password" required />
  </div>
 
  <!-- Mailing Lists -->
  <fieldset>
    <legend>
      <p>Select the email lists you want to subscribe to</p>
    </legend>
 
    <label>
      <input type="checkbox" id="register-mailing-marketing" />
      Marketing Emails
    </label>
 
    <label>
      <input type="checkbox" id="register-mailing-blog" />
      Blog Notifications
    </label>
 
    <label>
      <input type="checkbox" id="register-mailing-spam" />
      Constant Spam
    </label>
  </fieldset>
 
  <button type="submit">Register</button>
</form>

When the form is submitted you'll need to get the values with JavaScript, which you might do like this:

Form Submission w/o FormData
typescriptjavascript
const registerForm: HTMLFormElement = document.getElementById('register-form')
const emailInput: HTMLInputElement = document.getElementById('register-email')
const passwordInput: HTMLInputElement =
  document.getElementById('register-password')
const marketingMailingInput: HTMLInputElement = document.getElementById(
  'register-mailing-marketing'
)
const blogMailingInput: HTMLInputElement = document.getElementById(
  'register-mailing-blog'
)
const spamMailingInput: HTMLInputElement = document.getElementById(
  'register-mailing-spam'
)
 
registerForm.addEventListener('submit', event => {
  // Remember to prevent the default <form> submission behaviour
  // if you're handling submissions with JavaScript!
  event.preventDefault()
 
  const email = emailInput.value
  const password = passwordInput.value
 
  const acceptedMailingLists = []
 
  if (marketingMailingInput.checked) {
    acceptedMailingLists.push('marketing')
  }
  if (blogMailingInput.checked) {
    acceptedMailingLists.push('blog')
  }
  if (spamMailingInput.checked) {
    acceptedMailingLists.push('spam')
  }
 
  doRegister(email, password, acceptedMailingLists)
})
const registerForm = document.getElementById('register-form')
const emailInput = document.getElementById('register-email')
const passwordInput = document.getElementById('register-password')
const marketingMailingInput = document.getElementById(
  'register-mailing-marketing'
)
const blogMailingInput = document.getElementById('register-mailing-blog')
const spamMailingInput = document.getElementById('register-mailing-spam')
 
registerForm.addEventListener('submit', event => {
  // Remember to prevent the default <form> submission behaviour
  // if you're handling submissions with JavaScript!
  event.preventDefault()
 
  const email = emailInput.value
  const password = passwordInput.value
 
  const acceptedMailingLists = []
 
  if (marketingMailingInput.checked) {
    acceptedMailingLists.push('marketing')
  }
  if (blogMailingInput.checked) {
    acceptedMailingLists.push('blog')
  }
  if (spamMailingInput.checked) {
    acceptedMailingLists.push('spam')
  }
 
  doRegister(email, password, acceptedMailingLists)
})

This doesn't look that bad, but it's a lot of work that we could avoid. Consider (almost) the same script but now using FormData:

Form Submission with FormData
typescriptjavascript
const registerForm: HTMLFormElement = document.getElementById('register-form')
 
registerForm.addEventListener('submit', event => {
  // Remember to prevent the default <form> submission behaviour
  // if you're handling submissions with JavaScript!
  event.preventDefault()
 
  const formData = new FormData(registerForm)
 
  const email = formData.get('email')
  const password = formData.get('password')
 
  const acceptedMailingLists = formData.getAll('mailing-lists')
 
  doRegister(email, password, acceptedMailingLists)
})
const registerForm = document.getElementById('register-form')
 
registerForm.addEventListener('submit', event => {
  // Remember to prevent the default <form> submission behaviour
  // if you're handling submissions with JavaScript!
  event.preventDefault()
 
  const formData = new FormData(registerForm)
 
  const email = formData.get('email')
  const password = formData.get('password')
 
  const acceptedMailingLists = formData.getAll('mailing-lists')
 
  doRegister(email, password, acceptedMailingLists)
})

This is good, but there's one pretty big issue with this that I haven't mentioned yet, which leads into the a (kind of) downside with using FormData. The code above doesn't work - not because I suck at JavaScript or because FormData is too bleeding edge, but because we need to update the origina HTML to work with FormData, and be more correct.

In the code snippet above, we used a few strings as arguments to formData.get and formData.getAll, but where do these come from? When you use create a FormData object with a form, the keys we use in get and getAll map to the name attribute for any <input>s, <select>s, and <textarea>s inside the <form>. We can fix this issue by adding names to the inputs and adding values to the <input>s with type="checkbox":

Register Form After FormData :)
html
<form id="register-form">
  <!-- Email Input -->
  <div>
    <label for="register-email">Email</label>
    <input name="email" type="email" id="register-email" required />
  </div>
 
  <!-- Password Input -->
  <div>
    <label for="register-password">Password</label>
    <input name="password" type="password" id="register-password" required />
  </div>
 
  <!-- Mailing Lists -->
  <fieldset>
    <legend>
      <p>Select the email lists you want to subscribe to</p>
    </legend>
 
    <label>
      <input
        name="mailing-lists"
        value="marketing"
        type="checkbox"
        id="register-mailing-marketing"
      />
      Marketing Emails
    </label>
 
    <label>
      <input
        name="mailing-lists"
        value="blog"
        type="checkbox"
        id="register-mailing-blog"
      />
      Blog Notifications
    </label>
 
    <label>
      <input
        name="mailing-lists"
        value="spam"
        type="checkbox"
        id="register-mailing-spam"
      />
      Constant Spam
    </label>
  </fieldset>
 
  <button type="submit">Register</button>
</form>

If you scroll all the way to the bottom, you'll notice that all the checkbox <input>s all have the same name, and they each have a value. As mentioned before, the name is how FormData knows what to associate the input's value with, but when there are multiple inputs with the same name, we can get the values for all of them with getAll. Now, to know which checkbox have been selected, we give them unique values.

After making the changes above, everything works! Here's a demo of the complete code in a CodeSandbox:

React and Other Frameworks

If you've used React, you might notice a little issue with the using the code above - React doesn't (directly) give you access to the DOM elements created. You could use this weird hacky hook called useRef, but there's simpler solution. In fact, you can use this approach in any framework where you have access to the <form>s submission event. We can use the currentTarget property on methods to get access to the element that we added an event listener to, so currentTarget for a submit event will be the form! Here's what a simplified React component using FormData would look like:

FormData in React
typescriptjavascript
import type React from 'react'
 
export const ExampleForm: React.FC = () => {
  const handleSubmit: FormEventHandler<HTMLFormElement> = event => {
    // Remember to prevent the default <form> submission behaviour
    // if you're handling submissions with JavaScript!
    event.preventDefault()
 
    const formData = new FormData(event.currentTarget)
 
    const email = formData.get('email')
    const password = formData.get('password')
 
    const acceptedMailingLists = formData.getAll('mailing-lists')
 
    doRegister(email, password, acceptedMailingLists)
  }
 
  return (
    <form onSubmit={handleSubmit}>
      {/* Same form contents as the HTML form above */}
    </form>
  )
}
export const ExampleForm = () => {
  const handleSubmit = event => {
    // Remember to prevent the default <form> submission behaviour
    // if you're handling submissions with JavaScript!
    event.preventDefault()
 
    const formData = new FormData(event.currentTarget)
 
    const email = formData.get('email')
    const password = formData.get('password')
 
    const acceptedMailingLists = formData.getAll('mailing-lists')
 
    doRegister(email, password, acceptedMailingLists)
  }
 
  return (
    <form onSubmit={handleSubmit}>
      {/* Same form contents as the HTML form above */}
    </form>
  )
}

Notice how there aren't any useStates and you don't need to manually wire up value and onChange to control and get values for all of the inputs!

fetching with FormData

When you need to make a fetch request the values from form, you'll often get the specific values you need (as above), and make a request using them. However, if your API accepts application/x-www-form-urlencoded or multipart/form-data mime types for the request body (i.e. accepts those values for Content-Type), you can set the body of a fetch request to the FormData directly! Doing this also automatically adds the correct Content-Type header to your request.

typescriptjavascript
const formData = new FormData(document.getElementById('register-form'))
 
fetch('/your-endpoint', {
  method: 'POST',
  body: formData,
})
const formData = new FormData(document.getElementById('register-form'))
 
fetch('/your-endpoint', {
  method: 'POST',
  body: formData,
})

This is also the only way I know of right now that allows you to send or upload multiple files in a network request!

On the Server

Warning

The following will work only if the HTTP request has the Content-Type header set to application/x-www-form-urlencoded or multipart/form-data. If the header has a different value, or is unset an error will be thrown.

If you use a server-side framework / environment that uses Web API Requests (such as Next.js' middleware, Remix and Astro), you can use FormData on there too! Requests have a formData method that parses the request body as a FormData object, similar to how Response.json() parses a response's body as JSON.

For example, in Remix your actions have access to the request and you can access its body as a FormData object:

typescriptjavascript
import type { ActionArgs } from 'remix'
 
// This runs on the server!
export const action = async ({ request }: ActionArgs) => {
  const formData = await request.formData()
 
  const email = formData.get('email')
  const password = formData.get('password')
 
  const acceptedMailingLists = formData.getAll('mailing-lists')
 
  db.createAccount(email, password, acceptedMailingLists)
}
// This runs on the server!
export const action = async ({ request }) => {
  const formData = await request.formData()
 
  const email = formData.get('email')
  const password = formData.get('password')
 
  const acceptedMailingLists = formData.getAll('mailing-lists')
 
  db.createAccount(email, password, acceptedMailingLists)
}

A (Somewhat) Nice Hack

Warning

The following will only works well if every input in your form has different name attributes, and so doesn't really support inputs with type="checkbox".

FormData's interface is great, but sometimes you just need object to do the job. The following snippet converts a FormData to a plain JavaScript object where the keys and values correspond to those in your FormData.

typescriptjavascript
const formData = new FormData(document.getElementById('register-form'))
const dataAsObject = Object.fromEntries(formData as any)
const formData = new FormData(document.getElementById('register-form'))
const dataAsObject = Object.fromEntries(formData)

Warning

For at least version 4.8.4 and earlier, TypeScript doesn't recognise the required properties on FormData even though they're widely implemented so you need to cast the FormData object with as any.

Conclusion

FormData is great. Use it if you can.

Found a mistake, or want to suggest an improvement? Source on GitHub here
and see edit history here