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.
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.
If we remove the accept
attribute, the text file can be selected.
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)}
/>
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 aFileList
, even ifmultiple=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 afor...of
loop to traverse it.
Ok, so what about the File
s 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 ofBlob
and has a few of its own. So anything we can do with aBlob
, we can do with aFile
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?
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 anyFile
orBlob
.
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)
enablesfiles.map
()
further down. Remember thatinput.files
is aFileList
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.
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 aFileList
, 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.