React cookbook · Intermediate

Upload files with a progress bar in React

Upload large files directly to storage with a progress indicator and cancel button.

Approach

Ask your API for a pre-signed upload URL, then PUT the file directly to object storage. fetch doesn't expose upload progress, so use XMLHttpRequest (upload.onprogress) wrapped in a promise, with abort() for cancellation. Validate type and size on the client for UX and on the server for security.

Solution

import { useRef, useState } from "react";

function uploadWithProgress(url: string, file: File, onProgress: (pct: number) => void, signal: AbortSignal) {
  return new Promise<void>((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("PUT", url);
    xhr.setRequestHeader("Content-Type", file.type);
    xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(Math.round((e.loaded / e.total) * 100));
    xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error(`Upload failed: ${xhr.status}`)));
    xhr.onerror = () => reject(new Error("Network error"));
    signal.addEventListener("abort", () => { xhr.abort(); reject(new DOMException("Aborted", "AbortError")); });
    xhr.send(file);
  });
}

export function FileUploader() {
  const [progress, setProgress] = useState<number | null>(null);
  const controller = useRef<AbortController | null>(null);

  async function onChange(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;
    if (file.size > 50 * 1024 * 1024) return alert("Max 50 MB");
    const { uploadUrl, key } = await fetch("/api/uploads", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: file.name, type: file.type, size: file.size }),
    }).then((r) => r.json());
    controller.current = new AbortController();
    setProgress(0);
    await uploadWithProgress(uploadUrl, file, setProgress, controller.current.signal);
    await fetch(`/api/uploads/${key}/complete`, { method: "POST" });
    setProgress(null);
  }

  return (
    <div>
      <input type="file" accept="image/*,application/pdf" onChange={onChange} />
      {progress !== null && (
        <>
          <progress value={progress} max={100} aria-label="Upload progress">{progress}%</progress>
          <button onClick={() => controller.current?.abort()}>Cancel</button>
        </>
      )}
    </div>
  );
}

Pitfalls

  • Uploading through your API servers (bandwidth and memory).
  • Trusting client-side type checks for security.
  • Forgetting to handle aborted uploads in UI state.

Variations

  • Multipart uploads (S3) for files over ~100 MB with resumability.
  • Use a library like Uppy for drag-and-drop, retries and resumable uploads.

Further reading: MDN: upload progress event

Reference: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequestUpload/progress_event

More React recipes