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:

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:

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.