Build a Robust React Drag-and-Drop File Upload Component (With Previews, Progress, and Validation)
Build a React drag-and-drop file upload with previews, progress, validation, accessibility, and testing. Includes code for react-dropzone and a custom approach.
Image used for representation purposes only.
Overview
Drag-and-drop file upload is one of those small UX touches that makes a big difference. In React, you can build a polished dropzone that supports keyboard interaction, validation, previews, progress, and cancellations—without resorting to sprawling code. This article walks through both a library-based approach and a from-scratch implementation, then covers accessibility, performance, and testing.
What a great file uploader should do
- Accept drag-and-drop and traditional file selection
- Provide immediate visual feedback (hover/active/reject states)
- Validate file type, size, and count before uploading
- Show previews for images/videos and basic metadata for others
- Display upload progress with the ability to cancel
- Be accessible via keyboard and screen readers
- Fail gracefully, with clear error messaging
Quick start with react-dropzone (recommended)
Using a small, mature library keeps your component lean and robust.
Install
npm i react-dropzone
# or
pnpm add react-dropzone
Minimal component (TypeScript)
import React, { useCallback, useState } from 'react';
import { useDropzone, FileRejection } from 'react-dropzone';
import axios from 'axios';
type UploadItem = {
file: File;
preview?: string; // object URL for images/videos
progress: number; // 0-100
error?: string;
cancel?: AbortController;
};
export function ImageDropzone() {
const [items, setItems] = useState<UploadItem[]>([]);
const onDrop = useCallback((acceptedFiles: File[], fileRejections: FileRejection[]) => {
const next = acceptedFiles.map((file) => ({
file,
preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined,
progress: 0
}));
// Record client-side rejections
const rejected = fileRejections.map(r => ({ file: r.file, progress: 0, error: r.errors[0]?.message } as UploadItem));
setItems((prev) => [...prev, ...next, ...rejected]);
// Kick off uploads for accepted files
next.forEach(uploadWithAxios);
}, []);
const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({
accept: { 'image/*': [] },
maxFiles: 6,
maxSize: 5 * 1024 * 1024, // 5MB
onDrop
});
function uploadWithAxios(item: UploadItem) {
const form = new FormData();
form.append('file', item.file);
const controller = new AbortController();
setItems((prev) => prev.map((it) => (it.file === item.file ? { ...it, cancel: controller } : it)));
axios.post('/api/upload', form, {
signal: controller.signal,
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
const total = e.total ?? item.file.size;
const progress = total ? Math.round((e.loaded / total) * 100) : 0;
setItems((prev) => prev.map((it) => (it.file === item.file ? { ...it, progress } : it)));
},
}).catch((err) => {
setItems((prev) => prev.map((it) => (it.file === item.file ? { ...it, error: err.message } : it)));
});
}
function cancelUpload(file: File) {
const target = items.find((it) => it.file === file);
target?.cancel?.abort();
setItems((prev) => prev.map((it) => (it.file === file ? { ...it, error: 'Canceled' } : it)));
}
return (
<section>
<div
{...getRootProps({
className: `rounded-md border-2 border-dashed p-8 text-center outline-none transition-colors ${
isDragReject ? 'border-red-500 bg-red-50' : isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
}`
})}
aria-label="Image uploader"
>
<input {...getInputProps()} aria-label="Choose files" />
<p className="mb-2 font-medium">Drag & drop images here, or click to select</p>
<p className="text-sm text-gray-500">PNG/JPG up to 5MB. Max 6 files.</p>
</div>
<ul className="mt-4 grid grid-cols-2 gap-4 md:grid-cols-3">
{items.map((it) => (
<li key={it.file.name + it.file.size} className="rounded border p-2">
{it.preview ? (
<img src={it.preview} alt={it.file.name} className="mb-2 h-32 w-full rounded object-cover" onLoad={() => URL.revokeObjectURL(it.preview!)} />
) : (
<div className="mb-2 h-32 w-full rounded bg-gray-100 grid place-items-center text-xs text-gray-500">{it.file.type || 'file'}</div>
)}
<div className="mb-1 text-xs">{it.file.name} • {(it.file.size / 1024).toFixed(0)} KB</div>
<div className="h-2 w-full overflow-hidden rounded bg-gray-200">
<div className="h-full bg-blue-600 transition-all" style={{ width: `${it.progress}%` }} />
</div>
<div className="mt-1 flex items-center justify-between text-xs">
<span className={it.error ? 'text-red-600' : 'text-gray-600'}>{it.error ?? `${it.progress}%`}</span>
{!it.error && it.progress < 100 && (
<button className="text-blue-600 hover:underline" onClick={() => cancelUpload(it.file)}>Cancel</button>
)}
</div>
</li>
))}
</ul>
</section>
);
}
Why this works well:
- useDropzone abstracts the dragenter/dragover/drop event quirks
- Built-in validation (accept, maxSize, maxFiles)
- You retain control over actual uploads and UI
Building from scratch (no dependency)
If you prefer to own the full behavior, HTML5 drag-and-drop plus a hidden file input gets you there.
import React, { useRef, useState } from 'react';
type RawUploaderProps = {
onFiles: (files: File[]) => void;
accept?: string; // e.g., 'image/*,.pdf'
multiple?: boolean;
};
export function RawUploader({ onFiles, accept, multiple = true }: RawUploaderProps) {
const [active, setActive] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
function handleFiles(list: FileList | null) {
if (!list) return;
onFiles(Array.from(list));
}
return (
<div
role="button"
tabIndex={0}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && inputRef.current?.click()}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setActive(true);
}}
onDragLeave={() => setActive(false)}
onDrop={(e) => {
e.preventDefault();
setActive(false);
handleFiles(e.dataTransfer.files);
}}
className={`rounded-md border-2 border-dashed p-8 text-center ${active ? 'border-blue-500 bg-blue-50' : 'border-gray-300'}`}
aria-label="File uploader"
>
<input
ref={inputRef}
type="file"
className="sr-only"
onChange={(e) => handleFiles(e.currentTarget.files)}
accept={accept}
multiple={multiple}
/>
<p>Drag & drop files here, or press Enter to browse</p>
</div>
);
}
Key points:
- Always call preventDefault in onDragOver to allow dropping
- Keep a boolean drag state for styling
- Pair with a standard file input for keyboard accessibility
Validating files before upload
Client-side validation improves UX but never replaces server checks.
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_FILES = 6;
function validate(files: File[]): { ok: File[]; errors: { file: File; message: string }[] } {
const ok: File[] = [];
const errors: { file: File; message: string }[] = [];
files.slice(0, MAX_FILES).forEach((f) => {
if (!ALLOWED_TYPES.includes(f.type)) errors.push({ file: f, message: 'Unsupported file type' });
else if (f.size > MAX_SIZE) errors.push({ file: f, message: 'File too large' });
else ok.push(f);
});
if (files.length > MAX_FILES) {
errors.push({ file: files[MAX_FILES], message: `Too many files (max ${MAX_FILES})` });
}
return { ok, errors };
}
Uploading files with progress and cancellation
Fetch does not expose upload progress in a widely supported way yet. Use XMLHttpRequest or a small HTTP client that surfaces progress events.
import axios from 'axios';
export function uploadFile(file: File, url = '/api/upload', onProgress?: (n: number) => void) {
const form = new FormData();
form.append('file', file);
const controller = new AbortController();
const task = axios.post(url, form, {
headers: { 'Content-Type': 'multipart/form-data' },
signal: controller.signal,
onUploadProgress: (e) => {
const total = e.total ?? file.size;
const pct = total ? Math.round((e.loaded / total) * 100) : 0;
onProgress?.(pct);
},
});
return { task, cancel: () => controller.abort() };
}
Tips:
- Batch uploads, but limit concurrency (e.g., 3 at a time) to avoid saturating the network
- Surface server errors (HTTP 4xx/5xx) with human-friendly messages
- If targeting S3 or GCS, consider pre-signed URLs and direct-to-cloud uploads to offload your server
Previews done right
- Use URL.createObjectURL for local previews; revoke it after the image loads or on unmount to prevent memory leaks
- For large images, consider generating client-side thumbnails via Canvas to reduce memory
- For non-image files, show a generic icon plus file name, size, and type
useEffect(() => () => items.forEach((it) => it.preview && URL.revokeObjectURL(it.preview)), [items]);
Accessibility checklist
- Make the dropzone focusable (tabIndex=0) and operable with Enter/Space
- Provide an accessible name (aria-label or associated label)
- Announce validation errors using aria-live=“polite”
- Ensure color is not the only indicator (use icons/borders/text)
- Keep the native input visible to screen readers (do not aria-hidden it)
Example for announcing errors:
<div role="status" aria-live="polite" className="sr-only">{errorMessage}</div>
Styling and state cues
Communicate states clearly:
- Idle: neutral border and hint text
- Drag active: brand-colored border/background
- Drag reject: red border with a short reason (e.g., “Only images up to 5MB”)
- Uploading: progress bar and cancel action
- Success: brief checkmark animation or subtle color shift
Performance considerations
- Avoid storing massive File arrays in deeply nested state; track minimal metadata
- Revoke object URLs promptly
- Virtualize long lists of files (react-window) to keep the UI snappy
- Debounce drag state changes to prevent layout thrashing on noisy dragover events
- Limit simultaneous uploads; queue the rest
Security notes (client + server)
- The accept attribute and client validation are advisory; enforce type/size/virus scanning server-side
- Sanitize file names; never trust user-provided paths or extensions
- Store to immutable locations and serve via signed URLs when possible
- For images, consider transcoding and stripping metadata
Advanced features to consider
- Chunked/resumable uploads (e.g., S3 multipart or a tus-compatible server) for large files
- Pause/resume with persistent state (IndexedDB) for flaky connections
- Dragging from external sources (e.g., dropping a URL) and server-side fetching
- Image transformations (resize/compress) in a Web Worker to keep the main thread responsive
Testing your uploader
Use React Testing Library and user-event to verify:
- Keyboard activation triggers the file dialog
- Dragging over toggles the active state
- Dropping files calls onDrop with the right payload
- Progress updates render and cancel aborts the request
Simulating a drop:
const data = new DataTransfer();
data.items.add(new File(['hello'], 'greeting.txt', { type: 'text/plain' }));
fireEvent.drop(getByLabelText('File uploader'), { dataTransfer: data });
Server endpoints (example)
A minimal Node/Express route handling multipart form data with limits:
import express from 'express';
import multer from 'multer';
const app = express();
const upload = multer({
limits: { fileSize: 5 * 1024 * 1024 },
storage: multer.memoryStorage(), // or diskStorage / S3 stream
});
app.post('/api/upload', upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file' });
// TODO: validate MIME, scan, persist, create URL
return res.json({ ok: true });
});
Putting it together: recommended blueprint
- Start with react-dropzone for robust DnD and validation
- Use Axios/XMLHttpRequest for progress + cancel
- Keep state minimal: [{ file, preview, progress, error, cancel }]
- Make it accessible and keyboard-friendly
- Enforce limits server-side and sanitize aggressively
- Add niceties: thumbnails, retry, queued uploads, and clear error messages
Conclusion
A modern React drag-and-drop uploader is a small stack of concerns: interaction, validation, preview, transport, and feedback. With react-dropzone for the UI surface and a progress-aware upload function, you can ship a polished, accessible component that scales from a few images to large, resumable uploads—without overengineering. Start simple, keep state tidy, and add features as your product grows.
Related Posts
Building a High-Performance React Virtualized Tree View
A practical guide to designing and implementing a fast, accessible React virtualized tree view with lazy loading, keyboard support, and drag‑and‑drop.
Building an Accessible, Reusable React Progress Indicator (Linear and Circular)
Build an accessible, themeable React progress indicator (linear and circular) with TypeScript, complete code, a11y, performance tips, theming, and tests.
Build a Robust React Clipboard Copy/Paste Component (Hooks, A11y, Fallbacks)
Build a production-ready React clipboard copy/paste component with hooks, fallbacks, accessibility, sanitization, and tests.