File upload in React: dropzone, progress and previews

Build file upload in React with a drag and drop dropzone, accept rules, real upload progress, presigned S3 URLs, cancel and retry, and image previews.

By MiniDev17 min read

File upload in React comes down to a drop target that also opens the file picker, client-side checks for type and size, an upload function that reports progress (XMLHttpRequest, because fetch has no upload progress event), and a list that shows each file's state with cancel and retry. For anything larger than a few megabytes, upload straight to object storage with a presigned URL instead of through your server. MiniDev UI's free FileDropzone handles the drop target, validation, progress, retry and image previews; this guide shows how to plug real uploads into it.

The components

  • FileDropzone: the drop target with rest, hover, drag-accept and drag-reject states, plus a file list with thumbnails, progress bars, errors, retry and remove.
  • ImageUpload: a single-image field that swaps the dropzone for a 192px-tall preview with a remove button.
  • AvatarUpload: an avatar with an Upload button. It is a visual demo: it shows the picked image but has no onChange, so copy it and add one.
  • FileList and FileRow: rows for files that are already stored, with an optional download button.
bash
npx shadcn@latest add https://ui.minidev.pro/r/file-dropzone.json https://ui.minidev.pro/r/image-upload.json https://ui.minidev.pro/r/file-list.json https://ui.minidev.pro/r/progress.json

Accept rules, size limits and multiple files

tsx
<FileDropzone
  accept="image/*,application/pdf,text/csv,.csv"
  maxSize={10 * 1024 * 1024}
  maxFiles={5}
  onFiles={(files) => console.log(files)}
/>
  • accept uses the same syntax as the input's accept attribute: MIME types (application/pdf), wildcards (image/*) and extensions (.csv). It is passed to the native input, so the picker filters too.
  • While dragging, the zone reads the MIME types from dataTransfer.items and switches to a red reject state before the user lets go. Browsers do not expose file names during a drag, so an extension-only rule such as .csv cannot match yet and the zone shows the reject state even though the drop would succeed. List the MIME type next to the extension (text/csv,.csv) to avoid that.
  • maxSize is in bytes. Files that fail type or size stay in the list with a reason ("PDF files aren't accepted", "Larger than 10.0 MB") and no retry button, since retrying cannot fix them.
  • multiple defaults to true. With multiple={false} a new file replaces the previous one. maxFiles caps the list; extra files are ignored.
  • onFiles receives only the files that passed validation, every time files are added.

Client-side checks are for feedback, not security. Anyone can send any bytes to your endpoint. Check size and type again on the server, inspect the file's content rather than trusting its extension or declared MIME type, and store uploads under keys you generate, never under the user's file name.

Real progress with XMLHttpRequest

Pass an upload function and the dropzone runs it for each accepted file. It receives the File and an onProgress callback that takes a number from 0 to 1. Resolve when done; throw to mark the file as failed. fetch cannot report upload progress, so use XMLHttpRequest, whose upload.onprogress event reports bytes sent:

lib/put-with-progress.tsts
export function putWithProgress(
  url: string,
  file: File,
  onProgress: (p: number) => void,
  signal?: AbortSignal,
) {
  return new Promise<void>((resolve, reject) => {
    if (signal?.aborted) return reject(new DOMException("Upload canceled", "AbortError"))
    const xhr = new XMLHttpRequest()
    xhr.open("PUT", url)
    xhr.setRequestHeader("Content-Type", file.type || "application/octet-stream")
    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) onProgress(e.loaded / e.total)
    }
    xhr.onload = () =>
      xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error("Upload failed (" + xhr.status + ")"))
    xhr.onerror = () => reject(new Error("Network error, check your connection"))
    xhr.onabort = () => reject(new DOMException("Upload canceled", "AbortError"))
    signal?.addEventListener("abort", () => xhr.abort(), { once: true })
    xhr.send(file)
  })
}

The error message you throw is what the row shows, so write it for people. A failed row gets a retry button that runs your upload function again for the same file.

Direct to S3 with presigned URLs

Sending files through your own server wastes bandwidth and hits limits: Next.js server actions accept 1 MB request bodies by default, and serverless platforms cap request size. Instead, your server signs a short-lived URL and the browser uploads straight to storage. A route handler that signs an S3 PUT with the AWS SDK v3:

app/api/uploads/route.tsts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"

const s3 = new S3Client({ region: process.env.AWS_REGION })
const ALLOWED = new Set(["image/png", "image/jpeg", "image/webp", "application/pdf"])
const MAX_BYTES = 10 * 1024 * 1024

export async function POST(req: Request) {
  const user = await requireUser()
  const { type, size } = (await req.json()) as { type: string; size: number }
  if (!ALLOWED.has(type) || typeof size !== "number" || size > MAX_BYTES) {
    return Response.json({ error: "This file type or size is not allowed." }, { status: 400 })
  }
  const key = "uploads/" + user.id + "/" + crypto.randomUUID()
  const url = await getSignedUrl(
    s3,
    new PutObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key, ContentType: type }),
    { expiresIn: 60 },
  )
  return Response.json({ url, key })
}

On the client, the upload function asks for a URL, uploads with progress, then tells your server the upload finished so it can record the file:

tsx
async function uploadToS3(file: File, onProgress: (p: number) => void, signal?: AbortSignal) {
  const res = await fetch("/api/uploads", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ type: file.type, size: file.size }),
    signal,
  })
  if (!res.ok) throw new Error((await res.json()).error ?? "Could not start the upload")
  const { url, key } = await res.json()
  await putWithProgress(url, file, onProgress, signal)
  await fetch("/api/uploads/complete", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ key, name: file.name }),
  })
}

<FileDropzone accept="image/png,image/jpeg,image/webp,application/pdf" maxSize={10 * 1024 * 1024} upload={uploadToS3} />
  • The bucket needs a CORS rule that allows PUT from your origin with the Content-Type header, or the browser blocks the request before it starts.
  • The Content-Type header on the upload must match the ContentType you signed, or S3 rejects the signature.
  • A presigned PUT cannot enforce a maximum size by itself. In the complete handler, read the object's real size with HeadObjectCommand and delete anything that breaks your rules, or use a presigned POST policy with a content-length-range condition when you need a hard limit.
  • The same pattern works with S3-compatible stores such as Cloudflare R2 and MinIO.

Cancel and retry

Retry comes free with the built-in list. Cancel does not: the upload function gets no abort signal, and removing a row takes it off the list without stopping a request in flight. When you need a Cancel button, turn the list off with showList={false}, take files from onFiles, and render your own rows with an AbortController per file:

components/uploader.tsxtsx
"use client"
import * as React from "react"
import { FileDropzone } from "@/components/ui/file-dropzone"
import { Progress } from "@/components/ui/progress"
import { Button } from "@/components/ui/button"

type Item = { id: string; file: File; progress: number; status: "uploading" | "done" | "error" | "canceled"; error?: string }

export function Uploader() {
  const [items, setItems] = React.useState<Item[]>([])
  const controllers = React.useRef(new Map<string, AbortController>())
  const patch = (id: string, p: Partial<Item>) => setItems((l) => l.map((i) => (i.id === id ? { ...i, ...p } : i)))

  async function start(item: Item) {
    const ac = new AbortController()
    controllers.current.set(item.id, ac)
    patch(item.id, { status: "uploading", progress: 0, error: undefined })
    try {
      await uploadToS3(item.file, (p) => patch(item.id, { progress: p }), ac.signal)
      patch(item.id, { status: "done", progress: 1 })
    } catch (err) {
      if (ac.signal.aborted) patch(item.id, { status: "canceled" })
      else patch(item.id, { status: "error", error: err instanceof Error ? err.message : "Upload failed" })
    } finally {
      controllers.current.delete(item.id)
    }
  }

  return (
    <div>
      <FileDropzone
        accept="image/*,application/pdf"
        showList={false}
        onFiles={(files) => {
          const next = files.map((file): Item => ({ id: crypto.randomUUID(), file, progress: 0, status: "uploading" }))
          setItems((l) => [...l, ...next])
          next.forEach(start)
        }}
      />
      <ul className="mt-3 space-y-2" aria-label="Uploads">
        {items.map((i) => (
          <li key={i.id} className="rounded-xl border border-border bg-surface p-3">
            <div className="flex items-center gap-3 text-sm">
              <span className="min-w-0 flex-1 truncate text-fg">{i.file.name}</span>
              <span className="text-xs text-fg-muted">{i.status === "uploading" ? Math.round(i.progress * 100) + "%" : i.status}</span>
              {i.status === "uploading" ? (
                <Button size="xs" variant="ghost" onClick={() => controllers.current.get(i.id)?.abort()}>Cancel</Button>
              ) : null}
              {i.status === "error" || i.status === "canceled" ? (
                <Button size="xs" variant="outline" onClick={() => start(i)}>Retry</Button>
              ) : null}
            </div>
            {i.status === "uploading" ? (
              <Progress className="mt-2" value={Math.round(i.progress * 100)} aria-label={"Uploading " + i.file.name} />
            ) : null}
            {i.error ? <p className="mt-1 text-xs text-danger">{i.error}</p> : null}
          </li>
        ))}
      </ul>
    </div>
  )
}

With the built-in list off, files rejected by accept or maxSize never reach onFiles and nothing on screen says why. Keep accept on the zone for the picker filter and the drag-reject state, and check sizes yourself in onFiles so you can show a message for files that are too large.

Previews with object URLs

URL.createObjectURL(file) returns a blob: URL you can put in an img immediately, without reading the file into memory as a data URL. Each URL keeps the file alive until you call URL.revokeObjectURL or the page unloads. FileDropzone creates a thumbnail URL for every image/* file and revokes it when the row is removed and when the component unmounts. In your own components, tie the URL to an effect:

tsx
function useObjectUrl(file: File | null) {
  const [url, setUrl] = React.useState<string | null>(null)
  React.useEffect(() => {
    if (!file) return setUrl(null)
    const next = URL.createObjectURL(file)
    setUrl(next)
    return () => URL.revokeObjectURL(next)
  }, [file])
  return url
}

ImageUpload calls onChange(url, file) with a new object URL when an image is picked and onChange(null, null) when it is removed. It does not revoke those URLs, so do it in your handler when the value changes. Two more quirks to know: it takes the first file even if several are dropped, and its hint says "PNG, JPG up to 5MB" without enforcing a size, so edit the hint or add maxSize to the inner dropzone in your copy.

tsx
const [cover, setCover] = React.useState<string | null>(project.coverUrl)
const [coverFile, setCoverFile] = React.useState<File | null>(null)

<ImageUpload
  value={cover}
  onChange={(url, file) => {
    if (cover?.startsWith("blob:")) URL.revokeObjectURL(cover)
    setCover(url)
    setCoverFile(file ?? null)
  }}
/>

Accessibility

  • Drag and drop is never the only way in. The zone is a role="button" element in the tab order that opens the file picker on click, Enter or Space. The native input stays in the DOM, visually hidden and out of the tab order, so there is one tab stop, not two.
  • The hint text (accepted types and size) is linked with aria-describedby, so screen readers read the rules along with the control.
  • A polite live region announces "3 files added". Each row's retry and remove buttons are labeled with the file name, such as "Remove report.pdf".
  • Progress in rows is shown as a percentage in text; the thin bar is decorative. In custom lists, give Progress an aria-label as in the uploader above.
  • Visible focus uses the kit's 2px accent ring, and the drag states change the border, icon and text, not only color.

Showing stored files

After upload, list stored files with FileList. It takes files: { id, name, meta }[] and an optional onDownload(id); with it, each row shows a labeled download button. Generate a short-lived download URL on the server when the button is pressed, rather than rendering permanent public links:

tsx
<FileList
  files={attachments.map((a) => ({ id: a.id, name: a.name, meta: formatBytes(a.size) + " · " + a.uploadedAt }))}
  onDownload={async (id) => {
    const { url } = await getDownloadUrl(id)
    window.location.assign(url)
  }}
/>

When the list is empty, show an empty state with the dropzone in it rather than an empty box.

Components used

FileDropzone and ImageUpload are in the forms category; FileList and FileRow are in media; AvatarUpload is in settings, next to the profile form from the settings page guide. All are free under MIT. For a whole product with uploads, storage and permissions wired end to end, the MiniDev studio builds on this kit.

File DropzoneuiA React drag and drop upload zone with accept and reject states, type and size checks, image previews, per file progress, retry and screen reader updates.npx shadcn@latest add ui.minidev.pro/r/file-dropzone.jsonImage UploaduiA single image picker that starts as a drag and drop zone, then swaps to a large preview with a remove button. Returns an object URL and the File in React.npx shadcn@latest add ui.minidev.pro/r/image-upload.jsonFile ListuiA stacked list of file rows showing each name and metadata such as size or date, with an optional download button per item. Good for React attachment lists.npx shadcn@latest add ui.minidev.pro/r/file-list.json
bash
npx shadcn@latest add https://ui.minidev.pro/r/<name>.json

Components used in this guide

File DropzoneuiA React drag and drop upload zone with accept and reject states, type and size checks, image previews, per file progress, retry and screen reader updates.npx shadcn@latest add ui.minidev.pro/r/file-dropzone.jsonImage UploaduiA single image picker that starts as a drag and drop zone, then swaps to a large preview with a remove button. Returns an object URL and the File in React.npx shadcn@latest add ui.minidev.pro/r/image-upload.jsonAvatar UploaduiReact profile photo uploader with an avatar preview and an Upload button that opens a hidden image file input. Shows initials until a picture is chosen.npx shadcn@latest add ui.minidev.pro/r/avatar-upload.jsonFile ListuiA stacked list of file rows showing each name and metadata such as size or date, with an optional download button per item. Good for React attachment lists.npx shadcn@latest add ui.minidev.pro/r/file-list.jsonFile RowuiA single file row with an icon, a truncated name, an optional meta line and a ghost download button with an accessible label. Styled with Tailwind.npx shadcn@latest add ui.minidev.pro/r/file-row.jsonProgressuiA linear progress bar built on Base UI with track, indicator, label and value parts, a default aria label and smooth width transitions. Matches shadcn progress.npx shadcn@latest add ui.minidev.pro/r/progress.jsonButtonuiShadcn compatible React button on Base UI with eight variants, nine sizes including icon sizes, a pressed state and render prop support for links.npx shadcn@latest add ui.minidev.pro/r/button.json

Frequently asked questions

How do I show upload progress in React?

Use XMLHttpRequest and listen to xhr.upload.onprogress, which reports loaded and total bytes. fetch has no upload progress event. Pass the ratio to your UI, for example through FileDropzone's onProgress callback.

Do I need react-dropzone for drag and drop uploads?

No. Drag and drop needs a handful of events (dragenter, dragover, dragleave, drop) and a hidden file input. FileDropzone implements them in one file you own, with accept rules, size limits and keyboard support.

Should files go through my Next.js server or straight to S3?

Straight to storage with a presigned URL for anything beyond small files. Server actions default to a 1 MB body limit and serverless functions cap request size. Your server only signs the URL and records the result.

How do I preview an image before uploading it?

Call URL.createObjectURL(file) and use the result as the img source. Revoke it with URL.revokeObjectURL when the file is removed or the component unmounts, or the browser keeps the file in memory.

More guides