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
- Add optimistic updates to a todo list
- Build an accessible modal dialog
- Build infinite scroll with TanStack Query
- Call a cookie-authenticated API with CSRF protection
- Debounce a search input in React
- Handle errors gracefully with error boundaries
- Implement dark mode that follows the system
- Poll a long-running job's status