Web File API deep dive 🤿 (2024)
Tags: webdev, javascript, tutorial, html
Published: 2023-07-12 16:17:03

## Perquisites 

Before continuing, you should skim over the [MDN docs for file inputs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file) if you have never worked with them before. This post assumes basic familiarity with the `<input>` and `<form>` elements. I'll be covering the various interfaces that make up the web [File API](https://developer.mozilla.org/en-US/docs/Web/API/File_API).

> 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">`

```tsx
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 dialog](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sx3tkp5l6h1i86l3tcn7.gif)

The [`multiple`](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/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`](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/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](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept#unique_file_type_specifiers) for more special values and [here](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types) 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 dialog image only](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9c7ap0olph9sjgscjukx.png)

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

![File dialog any file](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5ua2yw8piufcvfemyf5p.png)

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.

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

![Console log the fiels](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xk5tnmuc06zk4aw1xh65.gif)

Every input has a `files` property that is a [`FileList`](https://developer.mozilla.org/en-US/docs/Web/API/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](https://youtu.be/5d5NJgO38AE?t=360). `FileList` is an [unmodifiable list](https://stackoverflow.com/questions/74630989/why-use-domstringlist-rather-than-an-array/74641156#74641156), 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`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...of) loop to traverse it.

Ok, so what about the `File`s in `FileList`? What's their story? [A glance at the MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/File) tells us `File` is a special kind of [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/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`](https://developer.mozilla.org/en-US/docs/Web/API/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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URLs). 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()](https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsBinaryString). The [`.readAsDataURL()`](https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL) method works the same way.

```tsx
<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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qmet85xzf5byt68czuqz.png)

> There is a synchronous version of `FileReader`, [FileReaderSync](https://developer.mozilla.org/en-US/docs/Web/API/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()`](https://developer.mozilla.org/en-US/docs/Web/API/Blob/arrayBuffer) `Blob` method to clone the file data into an [ArrayBuffer](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer).

```tsx
<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()`](https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL) to get a [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URLs) 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()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static). 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.

```tsx
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.

![Show image with objectURL](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5a8uqr6uweo3g5vf953r.png)

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>`.

```tsx
{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](https://youtu.be/zQSwPEqBiLU?t=1531)

## 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()`](https://developer.mozilla.org/en-US/docs/Web/API/FormData/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()](https://developer.mozilla.org/en-US/docs/Web/API/FormData/get)method will return only the first value at that key, whereas `.getAll()` returns all of the values stored at the key.

```tsx
<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](https://developer.mozilla.org/en-US/docs/Web/API/Blob#instance_properties) `Blob` properties.

```tsx
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, append them to `FormData`, and send them on their way.

```tsx
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, create a request with its own `FormData` for each, and then await them all.

```tsx
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.
```tsx
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](https://www.w3docs.com/snippets/css/how-to-customize-file-inputs.html).

If you want more on the topic of files and the web, MDN has [this great post](https://developer.mozilla.org/en-US/docs/Web/API/File_API/Using_files_from_web_applications) with lots of more information that is worth checking out.