React File Drag-and-Drop Upload: Progress Tracking, Multi-File Queuing, and Validation
Build a production file drag-and-drop upload component in React. Covers drag-over states, multi-file queue management, upload progress with XMLHttpRequest, file type and size validation, presigned S3 URLs, and accessible keyboard file input.
File upload components are deceptively complex. A polished implementation handles drag-over states, multi-file queues with per-file progress, file type and size validation before upload starts, graceful error recovery for individual files, and accessibility for keyboard users who can't drag. Skip any of these and the experience feels unfinished.
This guide builds the complete upload component from scratch โ no third-party upload library needed.
Types
// types/upload.ts
export type UploadStatus =
| "pending" // Queued, not yet started
| "uploading" // In progress
| "complete" // Successfully uploaded
| "error"; // Failed
export interface UploadFile {
id: string;
file: File;
status: UploadStatus;
progress: number; // 0โ100
error: string | null;
url: string | null; // Public URL after upload
}
export interface UploadConfig {
maxFileSizeBytes: number;
maxFiles: number;
acceptedTypes: string[]; // MIME types: ["image/jpeg", "image/png", "application/pdf"]
destination: string; // S3 folder prefix
}
useFileUpload Hook
// hooks/use-file-upload.ts
import { useState, useCallback, useRef } from "react";
import { nanoid } from "nanoid";
import type { UploadFile, UploadConfig } from "@/types/upload";
const DEFAULT_CONFIG: UploadConfig = {
maxFileSizeBytes: 10 * 1024 * 1024, // 10MB
maxFiles: 10,
acceptedTypes: ["image/jpeg", "image/png", "image/webp", "application/pdf"],
destination: "uploads",
};
export function useFileUpload(config: Partial<UploadConfig> = {}) {
const cfg = { ...DEFAULT_CONFIG, ...config };
const [files, setFiles] = useState<UploadFile[]>([]);
const abortControllers = useRef<Map<string, AbortController>>(new Map());
function updateFile(id: string, updates: Partial<UploadFile>) {
setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, ...updates } : f)));
}
function validateFile(file: File): string | null {
if (!cfg.acceptedTypes.includes(file.type)) {
const allowed = cfg.acceptedTypes.map((t) => t.split("/")[1].toUpperCase()).join(", ");
return `File type not allowed. Accepted: ${allowed}`;
}
if (file.size > cfg.maxFileSizeBytes) {
const maxMB = cfg.maxFileSizeBytes / 1024 / 1024;
return `File too large. Maximum size: ${maxMB}MB`;
}
return null;
}
async function uploadFile(uploadFile: UploadFile): Promise<void> {
const controller = new AbortController();
abortControllers.current.set(uploadFile.id, controller);
updateFile(uploadFile.id, { status: "uploading", progress: 0 });
try {
// 1. Get presigned URL from server
const metaRes = await fetch("/api/uploads/presign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: uploadFile.file.name,
contentType: uploadFile.file.type,
size: uploadFile.file.size,
destination: cfg.destination,
}),
signal: controller.signal,
});
if (!metaRes.ok) {
const { error } = await metaRes.json();
throw new Error(error ?? "Failed to get upload URL");
}
const { presignedUrl, publicUrl } = await metaRes.json();
// 2. Upload to S3 with progress tracking
// fetch() doesn't support upload progress โ use XMLHttpRequest
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
updateFile(uploadFile.id, { progress });
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Upload failed: ${xhr.statusText}`));
}
};
xhr.onerror = () => reject(new Error("Network error during upload"));
xhr.onabort = () => reject(new Error("Upload cancelled"));
controller.signal.addEventListener("abort", () => xhr.abort());
xhr.open("PUT", presignedUrl);
xhr.setRequestHeader("Content-Type", uploadFile.file.type);
xhr.send(uploadFile.file);
});
updateFile(uploadFile.id, { status: "complete", progress: 100, url: publicUrl });
} catch (err) {
if ((err as Error).message === "Upload cancelled") {
setFiles((prev) => prev.filter((f) => f.id !== uploadFile.id));
} else {
updateFile(uploadFile.id, {
status: "error",
error: err instanceof Error ? err.message : "Upload failed",
});
}
} finally {
abortControllers.current.delete(uploadFile.id);
}
}
const addFiles = useCallback(
(newFiles: FileList | File[]) => {
const fileArray = Array.from(newFiles);
const remaining = cfg.maxFiles - files.length;
if (remaining <= 0) return;
const toAdd: UploadFile[] = fileArray.slice(0, remaining).map((file) => {
const validationError = validateFile(file);
return {
id: nanoid(),
file,
status: validationError ? "error" : "pending",
progress: 0,
error: validationError,
url: null,
};
});
setFiles((prev) => [...prev, ...toAdd]);
// Start uploading valid files immediately
toAdd
.filter((f) => f.status === "pending")
.forEach((f) => uploadFile(f));
},
[files.length, cfg.maxFiles]
);
function removeFile(id: string) {
// Cancel in-progress upload
abortControllers.current.get(id)?.abort();
setFiles((prev) => prev.filter((f) => f.id !== id));
}
function retryFile(id: string) {
const file = files.find((f) => f.id === id);
if (!file) return;
updateFile(id, { status: "pending", error: null, progress: 0 });
uploadFile(file);
}
const successfulUrls = files
.filter((f) => f.status === "complete" && f.url)
.map((f) => f.url!);
return {
files,
addFiles,
removeFile,
retryFile,
successfulUrls,
isUploading: files.some((f) => f.status === "uploading"),
config: cfg,
};
}
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Dropzone Component
// components/upload/dropzone.tsx
"use client";
import { useCallback, useState, useRef } from "react";
import { Upload, File as FileIcon, X, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
import { useFileUpload } from "@/hooks/use-file-upload";
import type { UploadFile, UploadConfig } from "@/types/upload";
interface DropzoneProps {
config?: Partial<UploadConfig>;
onUploadComplete?: (urls: string[]) => void;
label?: string;
}
export function Dropzone({ config, onUploadComplete, label }: DropzoneProps) {
const { files, addFiles, removeFile, retryFile, successfulUrls, config: cfg } = useFileUpload(config);
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
// Only clear dragging if leaving the dropzone entirely (not entering a child)
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragging(false);
}
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files.length > 0) {
addFiles(e.dataTransfer.files);
onUploadComplete?.(successfulUrls);
}
},
[addFiles, successfulUrls, onUploadComplete]
);
const acceptString = cfg.acceptedTypes.join(",");
const maxMB = Math.round(cfg.maxFileSizeBytes / 1024 / 1024);
return (
<div className="space-y-3">
{/* Drop zone */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
role="button"
tabIndex={0}
aria-label={label ?? "Upload files"}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") inputRef.current?.click(); }}
className={`
relative flex flex-col items-center justify-center gap-3
p-8 border-2 border-dashed rounded-xl cursor-pointer
transition-colors duration-150 select-none
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2
${isDragging
? "border-blue-500 bg-blue-50"
: "border-gray-300 bg-gray-50 hover:border-blue-400 hover:bg-blue-50/50"
}
`}
>
<input
ref={inputRef}
type="file"
multiple={cfg.maxFiles > 1}
accept={acceptString}
className="hidden"
onChange={(e) => { if (e.target.files) addFiles(e.target.files); }}
aria-hidden="true"
/>
<div className={`p-3 rounded-full ${isDragging ? "bg-blue-100" : "bg-gray-100"}`}>
<Upload className={`w-6 h-6 ${isDragging ? "text-blue-600" : "text-gray-400"}`} />
</div>
<div className="text-center">
<p className="text-sm font-medium text-gray-700">
{isDragging ? "Drop files here" : "Drag files here or click to browse"}
</p>
<p className="text-xs text-gray-400 mt-1">
{cfg.acceptedTypes.map((t) => t.split("/")[1].toUpperCase()).join(", ")} up to {maxMB}MB
{cfg.maxFiles > 1 ? ` ยท Max ${cfg.maxFiles} files` : ""}
</p>
</div>
</div>
{/* File queue */}
{files.length > 0 && (
<ul className="space-y-2" aria-label="Upload queue">
{files.map((uploadFile) => (
<FileQueueItem
key={uploadFile.id}
uploadFile={uploadFile}
onRemove={() => removeFile(uploadFile.id)}
onRetry={() => retryFile(uploadFile.id)}
/>
))}
</ul>
)}
</div>
);
}
File Queue Item
// components/upload/file-queue-item.tsx
import { X, RefreshCw, CheckCircle, AlertCircle, FileText, Image } from "lucide-react";
import type { UploadFile } from "@/types/upload";
function FileIcon({ mimeType }: { mimeType: string }) {
if (mimeType.startsWith("image/")) return <Image className="w-4 h-4 text-blue-500" />;
return <FileText className="w-4 h-4 text-gray-400" />;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
interface FileQueueItemProps {
uploadFile: UploadFile;
onRemove: () => void;
onRetry: () => void;
}
export function FileQueueItem({ uploadFile, onRemove, onRetry }: FileQueueItemProps) {
const { file, status, progress, error } = uploadFile;
return (
<li className="flex items-center gap-3 p-3 bg-white border border-gray-200 rounded-lg">
<FileIcon mimeType={file.type} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{file.name}</p>
<p className="text-xs text-gray-400">{formatBytes(file.size)}</p>
{/* Progress bar */}
{status === "uploading" && (
<div className="mt-1.5">
<div className="w-full bg-gray-100 rounded-full h-1">
<div
className="h-1 bg-blue-500 rounded-full transition-all duration-200"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-xs text-gray-400 mt-0.5">{progress}%</p>
</div>
)}
{/* Error message */}
{status === "error" && error && (
<p className="text-xs text-red-600 mt-0.5">{error}</p>
)}
</div>
{/* Status icon */}
<div className="flex-shrink-0">
{status === "complete" && (
<CheckCircle className="w-5 h-5 text-green-500" aria-label="Upload complete" />
)}
{status === "error" && (
<AlertCircle className="w-5 h-5 text-red-500" aria-label="Upload failed" />
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1">
{status === "error" && (
<button
onClick={onRetry}
className="p-1 text-gray-400 hover:text-blue-600 rounded"
aria-label="Retry upload"
>
<RefreshCw className="w-4 h-4" />
</button>
)}
{status !== "complete" && (
<button
onClick={onRemove}
className="p-1 text-gray-400 hover:text-red-600 rounded"
aria-label="Remove file"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</li>
);
}
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
API: Presigned URL Endpoint
// app/api/uploads/presign/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { nanoid } from "nanoid";
import { z } from "zod";
const s3 = new S3Client({ region: process.env.AWS_REGION! });
const ALLOWED_TYPES = [
"image/jpeg", "image/png", "image/webp", "image/gif",
"application/pdf", "text/csv",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
];
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
const PresignSchema = z.object({
filename: z.string().max(255),
contentType: z.string(),
size: z.number().int().positive().max(MAX_SIZE),
destination: z.string().regex(/^[a-z0-9-/]+$/).default("uploads"),
});
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const parsed = PresignSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
}
const { filename, contentType, size, destination } = parsed.data;
if (!ALLOWED_TYPES.includes(contentType)) {
return NextResponse.json({ error: "File type not allowed" }, { status: 400 });
}
// Sanitize filename: remove path traversal, keep extension
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
const safeFilename = `${nanoid()}.${ext}`;
const key = `${destination}/${session.user.workspaceId}/${safeFilename}`;
const presignedUrl = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: process.env.S3_BUCKET_NAME!,
Key: key,
ContentType: contentType,
ContentLength: size,
}),
{ expiresIn: 300 } // 5 minutes
);
const publicUrl = `${process.env.NEXT_PUBLIC_CDN_URL}/${key}`;
return NextResponse.json({ presignedUrl, publicUrl, key });
}
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Basic single-file upload | 1 dev | 0.5โ1 day | $200โ400 |
| Full dropzone (multi-file, progress, validation) | 1 dev | 2โ3 days | $600โ1,200 |
| + Image preview + crop + resize | 1 dev | 3โ5 days | $1,200โ2,500 |
See Also
- SaaS CSV Import with Validation
- SaaS Workspace Settings with Logo Upload
- AWS Parameter Store and Secrets Manager
- React Optimistic Updates
- React Accessibility and ARIA Patterns
Working With Viprasol
File upload UX makes or breaks products that handle documents, images, or data files. Our team builds upload components with real progress tracking via XMLHttpRequest (since fetch doesn't support upload progress), per-file cancellation, retry on failure, keyboard accessibility, and presigned S3 URLs so file bytes never pass through your server.
What we deliver:
useFileUploadhook with XHR-based progress, per-file abort controllers, and retry- Dropzone component with drag-over state, keyboard support, and ARIA labels
FileQueueItemwith progress bar, error message, retry and cancel buttons- Presigned URL API with filename sanitization, type allowlist, and size limit
- Multi-file support with configurable per-upload and total limits
Talk to our team about your file upload architecture โ
Or explore our web development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.