David Maillo
How to make a file uploader in React
A file uploader looks like one input and one request. This guide builds one in React in three steps: the simplest version that works, a solid everyday version, and then the problems waiting for you after that.
Level 1: the simplest uploader that works
The browser already knows how to pick files. All React has to do is hold on to the one the user chose and send it somewhere.
import { useState } from "react"
type Status = "idle" | "uploading" | "done" | "error"
export function FileUploader() {
const [file, setFile] = useState<File | null>(null)
const [status, setStatus] = useState<Status>("idle")
async function handleUpload() {
if (!file) return
setStatus("uploading")
const body = new FormData()
body.append("file", file)
try {
const res = await fetch("/api/upload", { method: "POST", body })
setStatus(res.ok ? "done" : "error")
} catch {
setStatus("error")
}
}
return (
<div>
<input
type="file"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
<button
onClick={handleUpload}
disabled={!file || status === "uploading"}
>
Upload
</button>
{status === "done" && <p>Uploaded.</p>}
{status === "error" && <p>Upload failed.</p>}
</div>
)
}Two things worth knowing already:
- Send the file as
FormDataand don't set theContent-Typeheader yourself. The browser sets it, along with the boundary that separates the parts of the body. If you set it by hand, the server can't read the file. e.target.filesis aFileList, not an array. It holdsFileobjects, each with aname, asizein bytes and atypelikeimage/png.
On the server, something has to receive it. In a Next.js route handler that looks like this:
// app/api/upload/route.ts
export async function POST(request: Request) {
const data = await request.formData()
const file = data.get("file")
if (!(file instanceof File)) {
return Response.json({ error: "No file received" }, { status: 400 })
}
// Save it wherever your files live: disk, S3, Supabase...
return Response.json({ name: file.name, size: file.size })
}That's a working uploader. For an internal tool, it might be all you need.
Level 2: drag and drop, validation, previews and progress
Real users expect more. They want to drop files onto the page, see what they picked, watch it go up, and stop it if they chose the wrong one.
A drop zone
Dropping files needs three drag events. The one people forget is
dragover: unless you call preventDefault() on it, the browser ignores the
drop and opens the file in the tab instead.
import { useRef, useState } from "react"
export function DropZone({ onFiles }: { onFiles: (files: File[]) => void }) {
const [isDragging, setIsDragging] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
return (
<div
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault()
setIsDragging(false)
onFiles(Array.from(e.dataTransfer.files))
}}
onClick={() => inputRef.current?.click()}
className={isDragging ? "dropzone dragging" : "dropzone"}
>
Drop files here, or click to browse
<input
ref={inputRef}
type="file"
multiple
hidden
onChange={(e) => {
onFiles(Array.from(e.target.files ?? []))
// Without this, picking the same file again fires no change event.
e.target.value = ""
}}
/>
</div>
)
}The hidden input keeps click to browse working, so the zone isn't
drag-only. And resetting value after each pick matters more than it
looks: without it, a user who removes a file and picks it again gets
nothing.
Validation
Check every file before it goes anywhere. The accept attribute on the
input is only a hint for the picker. Dropped files skip it completely.
const MAX_SIZE = 5 * 1024 * 1024 // 5 MB
const ALLOWED_TYPES = ["image/png", "image/jpeg"]
function validate(file: File): string | null {
if (!ALLOWED_TYPES.includes(file.type)) return "Only PNG or JPG files"
if (file.size > MAX_SIZE) return "Files must be 5 MB or smaller"
return null
}Tell the user why a file was turned away. A file that silently doesn't appear looks like a bug.
Previews
URL.createObjectURL gives you a local URL for a file, so an image can show
up before it's uploaded. Each one holds memory until you revoke it, so tie it
to the component's lifetime.
import { useEffect, useState } from "react"
export function Preview({ file }: { file: File }) {
const [url, setUrl] = useState<string>()
useEffect(() => {
const objectUrl = URL.createObjectURL(file)
setUrl(objectUrl)
return () => URL.revokeObjectURL(objectUrl)
}, [file])
if (!url) return null
return <img src={url} alt={file.name} width={64} height={64} />
}Progress and cancel
Here's the surprise at this level: fetch has no upload progress event. For
a progress bar you need XMLHttpRequest, which reports bytes sent through
xhr.upload.onprogress. An AbortSignal gives you a clean way to cancel.
export function uploadFile(
file: File,
onProgress: (percent: number) => void,
signal: AbortSignal,
) {
return new Promise<void>((resolve, reject) => {
if (signal.aborted) {
return reject(new DOMException("Aborted", "AbortError"))
}
const xhr = new XMLHttpRequest()
xhr.open("POST", "/api/upload")
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
onProgress(Math.round((e.loaded / e.total) * 100))
}
}
xhr.onload = () =>
xhr.status >= 200 && xhr.status < 300
? resolve()
: reject(new Error(`Upload failed with ${xhr.status}`))
xhr.onerror = () => reject(new Error("Network error"))
xhr.onabort = () => reject(new DOMException("Aborted", "AbortError"))
signal.addEventListener("abort", () => xhr.abort(), { once: true })
const body = new FormData()
body.append("file", file)
xhr.send(body)
})
}Putting it together
Each file gets its own entry with its own progress, status and
AbortController, so cancelling one doesn't touch the others.
import { useState } from "react"
import { DropZone } from "./DropZone"
import { Preview } from "./Preview"
import { uploadFile } from "./uploadFile"
import { validate } from "./validate"
type Item = {
id: string
file: File
progress: number
status: "uploading" | "done" | "error" | "canceled"
controller: AbortController
}
export function Uploader() {
const [items, setItems] = useState<Item[]>([])
const [message, setMessage] = useState<string | null>(null)
const update = (id: string, patch: Partial<Item>) =>
setItems((all) =>
all.map((item) => (item.id === id ? { ...item, ...patch } : item)),
)
function addFiles(files: File[]) {
setMessage(null)
for (const file of files) {
const problem = validate(file)
if (problem) {
setMessage(`${file.name}: ${problem}`)
continue
}
const item: Item = {
id: crypto.randomUUID(),
file,
progress: 0,
status: "uploading",
controller: new AbortController(),
}
setItems((all) => [...all, item])
uploadFile(file, (progress) => update(item.id, { progress }), item.controller.signal)
.then(() => update(item.id, { status: "done", progress: 100 }))
.catch((err) =>
update(item.id, {
status: err.name === "AbortError" ? "canceled" : "error",
}),
)
}
}
return (
<div>
<DropZone onFiles={addFiles} />
{message && <p role="alert">{message}</p>}
<ul>
{items.map((item) => (
<li key={item.id}>
<Preview file={item.file} />
{item.file.name}
<progress value={item.progress} max={100} />
{item.status === "uploading" && (
<button onClick={() => item.controller.abort()}>Cancel</button>
)}
{item.status === "error" && <span>Upload failed</span>}
{item.status === "canceled" && <span>Canceled</span>}
</li>
))}
</ul>
</div>
)
}That's a decent uploader. It handles several files, validates them, shows previews and progress, and cancels on request. Most apps ship something very close to this, and for a while it seems fine.
Level 3: the edge cases nobody thinks about at the start
This is where the estimate falls apart. Nothing below shows up while you build. It shows up weeks later, when real people use the thing with real files. I won't solve these here, because each one deserves its own post. Here's what's waiting:
- The drag state flickers.
dragleavefires every time the pointer crosses into a child element, so the highlight blinks on and off while the user is still over the zone. - Someone drops a folder. It arrives as a
Filewith no type and a size the OS made up. It passes your checks and then fails to upload. file.typecan lie. It comes from the file extension. An.exerenamed to.pngsails through your validation. Knowing what a file really is means reading its first bytes.- The copy and the checks drift apart. The text says "PNG or JPG, up to 5 MB". Months later someone changes the check and forgets the text. Now users get rejected for files they were told were fine.
- Retry is pressed twice. Two requests race for one progress bar.
- The server answers after a cancel. Depending on your upload code, a late response can mark a file as done after the user stopped it.
- The user leaves mid-upload. The component unmounts and the requests keep going in the background.
- Large files. Sending a 2 GB video through your own server isn't a plan. You'll want presigned URLs or multipart uploads straight to storage, and your storage credentials must never reach the browser.
- File names with
#,?or spaces break the storage URL they end up in. - Paste. People expect Ctrl+V to attach a screenshot. Then an image pasted into the comment box next to your uploader gets attached too, and two uploaders on one page both take it.
- Video previews. An
<img>can't show a video, so your preview turns into a broken image. - Accessibility. A
divwith a click handler can't be reached with a keyboard, and says nothing to a screen reader. Your animations should also respect reduced motion. - Everything else around it. Sizes shown in the right unit, error messages in more than one language, light and dark themes, an avatar that needs cropping before it goes up.
- And it has to look good. An upload box is often in a user's first minutes with your product. The grey dashed rectangle tells them nobody thought about that moment.
None of this is hard on its own. Together it's weeks of work that never appears on a roadmap, and every team finds it the same way: one bug report at a time.
Or skip level 3
You can build all of this yourself. You now know where the traps are. Or you can spend a small one-time payment on a component where it's already done.
Drop Upload is a drag and drop file upload component for React that handles every case in the list above. It checks the first bytes of each file, turns folders away with a clear reason, and keeps its instruction text and its validation on the same props. It uploads to your own REST endpoint, S3, S3 multipart, Supabase or Cloudinary, with progress, cancel and retry on every file. It comes with an avatar cropper, paste to upload, video thumbnails, keyboard support and four languages, and it looks the part.
You get it as source code, in TypeScript and plain JavaScript, a folder you
copy into your project and can change however you like. React and motion are
the only things it needs. It costs less than the afternoon you'd spend
chasing the first bug on that list.
Try it in the playground, read why I built it, or see the pricing.