Web File API deep dive 🤿

Web File API deep dive 🤿

The last file tutorial you'll ever need

·

9 min read

I am using React in this post, but you could adapt the code for use with vanilla JavaScript or a different frontend library without many changes.

Getting the files: The <input type="file">

export default function FileUploader() {
  return (
    <form>
      <input
        name="fileInput"
        type="file"
        accept="image/*"
        multiple
      />
    </form>
  )
}

Here we have a React component that renders a form containing an input of type file. Each browser renders a file input differently, but its behavior will be the same across them all. I am using Firefox.

Clicking on Browse... (or whatever your browser renders) opens a file selection dialog.

Opening the file dialog

The multiple attribute is a boolean that controls if the user can upload 1 or multiple files. It is false by default.

For the remainder of this post, we will have it set to true, so the code needs to be able to handle multiple files.

The accept attribute is a string that determines what types of files to allow the user to select in the dialog. accept="image/*" is a special value that allows all image-type files. See here for more special values and here for common MIME types that can also be used.

This is what the dialog looks like on MacOS. Notice the text file cannot be selected.

File image only dialog

If we remove the accept attribute, the text file can be selected.

Any file dialog

Even with the accept attribute set, YOU ALWAYS NEED TO VALIDATE USER-PROVIDED FILES MANUALLY. A user could open the dev tools and remove accept or find some other workaround. Never trust user input, especially when it comes to files that could take up a lot of bandwidth and storage and hike up a cloud bill. At the very minimum do a client-side check, but also on the server if possible. Later on in the post, I will show you a way to do client-side validation.

Working with files: The FileList and File interfaces

Let's add an onChange handler to the input and see what kind of juicy data we get from it.

<input
  onChange={(e) => console.log(e.target.files)}
/>

Console logging the files

Every input has a files property that is a FileList object. So it's no surprise we see FileList [ File, File ] in the console.

input.files will ways be a FileList, even if multiple=false is set in the input.

FileList [ File, File ] looks like an array and it behaves like an array when you interact with it in the console. Based on that, you would think FileList is an array and you can call all your favorite array methods on it. However, if you try this you will (like me when I made such a bold assumption) be left with immeasurable disappointment and a ruined day. FileList is an unmodifiable list, a special data type (and a rare find these days) with a whopping 1 method, .item(index), which returns the file at the given index. No .map() or .forEach() here.

FileList is iterable, so we can use a for...of loop to traverse it.

Ok, so what about the Files in FileList? What's their story? A glance at the MDN docs tells us File is a special kind of Blob, which is itself just a generic way to represent data. File has a few extra properties Blob does not. The most useful one is .name, which is the name of the file from the user's device. Like FileList, both File and Blob are read-only interfaces. We will see how we can manipulate its data despite this shortly.

The take-home point here is that File inherits all the methods and properties of Blob and has a few of its own. So anything we can do with a Blob, we can do with a File

Doing something with files: The FileReader, Blob, and objectUrl interfaces

To read the content of a file, we can use the aptly named FileReader API. There are only two use cases where it makes sense to use FileReader. The first is getting the file content as a binary string. The other is getting it as a data URL. Most other use cases should be covered by Blob instance methods or objectURL.

Here is an example of reading the files as binary strings with FileReader and .readAsBinaryString(). The .readAsDataURL() method works the same way.

<input
  onChange={(e) => {
    const files = e.target.files || []
      for (const file of files) {
        const reader = new FileReader()
        reader.onload = () => {
          console.log(reader.result)
        }
        reader.readAsBinaryString(file)
    }
  }}
  ...    
/>

Here is the result of that. Don't you just love some raw binary gibberish?

Console log binary data

There is a synchronous version of FileReader, FileReaderSync, but it is only accessible in Web Workers to prevent blocking the main thread.

If you want to do any kind of manipulation of the content of a file (remember, File and Blob are read-only), use the .arrayBuffer() Blob method to clone the file data into an ArrayBuffer.

<input
  onChange={async (e) => {
    const files = e.target.files || []
    for (const file of files) {
      const arrayBuffer = await file.arrayBuffer()
      const typedArray = new Uint8Array(arrayBuffer)
      // Do something with the data here
    }
  }}
  ...    
/>

What if we want to display the selected files in the component? We could use a FileReader and .readAsDataURL() to get a data URL to use as an image src, and that should work, but there is a better, non-event-based way to do it.

Enter URL.createObjectURL(). We pass a File or Blob to this static method and it returns a URL we can use as an image src.

URL.createObjectURL() is not just for images. It can create a URL representation for any File or Blob.

Putting all this together, we can update the component to something like this.

import { useState } from "react"

export default function FileUploader() {
  const [files, setFiles] = useState<File[]>([])
  return (
    <form>
      <input
        name="fileInput"
        type="file"
        accept="image/*"
        multiple
        onChange={(e) => {
          setFiles(Array.from(e.target.files || []))
        }}
      />
      {files.map((file) => (
        <img
          key={file.name}
          src={URL.createObjectURL(file)}
          alt={file.name}
          width={200}
          height={200}
        />
      ))}
    </form>
  )
}

Array.from(e.target.files) enables files.map() further down. Remember that input.files is a FileList without array methods. So we create an array that will be easier to work with.

Here is what it looks like when I select two images.

Displaying images with objectURL

If we open the inspector and look at the src of the <img> we will see something like blob:http://localhost:3000/0467eeb6-697d-4737-98db-fdef50e78637. This URL is a reference to the file created within the context of the current document. This means the URL will continue to work until the page it was generated on is refreshed or navigated away from, at which point the URL is revoked and stops working.

If you need to revoke a URL programmatically, you can use URL.revokeObjectURL('blob:http....'). A situation that would require this is client-side navigation. For example, when using the Next.js Link and router, the object URL is not revoked because the document is not unloaded. To ensure memory safety, add a mechanism to manually revoke the URL. This could be done with the onLoad handler of an <img>.

{files.map((file) => {
  const objectUrl = URL.createObjectURL(file)
    return (
      <img
        key={file.name}
        src={objectUrl}
        alt={file.name}
        width={200}
        height={200}
        onLoad={() => URL.revokeObjectURL(objectUrl)}
      />
    )
})}

Fun fact: Blobs and URL.createObjectURL() are what power the thumbnails that appear when you hover over the video progress bar on Youtube or Netflix. Here is a Netflix engineer discussing this

Sending the file somewhere else

Now that we have played around with the files on the client, it's time to send them off to a server somewhere. This can be done by adding an onSubmit handler to the form.

For inputs that allow multiple files, access them within the submit handler using the .getAll() FormData method. The reason to use this method is when an input allows multiple files, they are all stored as form values under the same key, in this example, the key is fileInput (from the name attribute of the <input>). Using the more common .get()method will return only the first value at that key, whereas .getAll() returns all of the values stored at the key.

<form
  onSubmit={async (e) => {
    e.preventDefault()
    const files = new FormData(e.target as HTMLFormElement).getAll('fileInput')
  }}
>

Another reason to use .getAll() is it will return the files in an array and not a FileList, making our lives easier.

Next, run the files through some validation. What this looks like will depend on the use case. Here is an example checking the type and size Blob properties.

onSubmit={async (e) =>{
  files.forEach((file) => {
    // `.type` is the file MIME type
    if (file.type !== "desired/type") {
      // File type error logic here
    }
    // `.size` is the file size in bytes
    if (file.size > 10_000_000) {
      // File size error logic here
    }
  })
}}

With the files validated, it's time to send them to the server. You will need to determine how your server expects to receive files. Here are two patterns.

The first is the server can handle all of the files stored under the same FormData key. Loop through the files, and append them to FormData, and send them on their way.

onSubmit={async (e) =>{
  const formData = new FormData()
  files.forEach((file) => formData.append("my_files", file))
  const resp = await fetch("...", { method: "POST", body: formData })
  // Do something with the response
}}

Next is if the server needs the files uploaded individually. Loop through the files, and create a request with its own FormData for each, and then await them all.

onSubmit={async (e) =>{
  const requests = files.map((file) => {
  const formData = new FormData()
  formData.append("my_file", file)
    return fetch("...", { method: "POST", body: formData })
  })
  const responses = await Promise.all(requests)
  // Do something with the responses
}}

Putting it all together

Combining everything covered, the component might look something like this.

import { useState } from "react"

export default function FileUploader() {
  const [files, setFiles] = useState<File[]>([])

  return (
    <form
      onSubmit={async (e) => {
        e.preventDefault()
        const files = new FormData(e.target as HTMLFormElement).getAll('fileInput')

        files.forEach((file) => {
          if (file.type !== "desired/type") {
            throw new Error("Wrong file type")
          }
          if (file.size > 10_000_000) {
            throw new Error("File size is too big")
          }
        })

        const requests = files.map((file) => {
          const formData = new FormData()
          formData.append("my_file", file)
          return fetch("...", { method: "POST", body: formData })
        })
        await Promise.all(requests) 
      }}
    >
      <input
        name="fileInput"
        type="file"
        accept="image/*"
        multiple
        onChange={(e) => {
          setFiles(Array.from(e.target.files || []))
        }}
      />
      <button type="submit">
        Submit
      </button>
      {files.map((file) => {
        const objectUrl = URL.createObjectURL(file)
        return (
          <img
            key={file.name}
            src={objectUrl}
            alt={file.name}
            width={200}
            height={200}
            onLoad={() => URL.revokeObjectURL(objectUrl)}
          />
        )
      })}
    </form>
  )
}

Once you have the functionality down, be sure to add style and accessibility features! Here is a good place to start if you need a guide.

If you want more on the topic of files and the web, MDN has this great post with lots of more information that is worth checking out.

Did you find this article valuable?

Support Nobel 7 by becoming a sponsor. Any amount is appreciated!

Â