TL;DR: React Dropzone wraps HTML5 File API drag events into a clean
useDropzone()hook. Handles click-to-browse, MIME type filtering, file size limits, and multi-file support. Pair it with React Hook Form's<Controller>for validated file uploads. Official docs: react-dropzone.js.org.
What is React Dropzone?
React Dropzone provides a useDropzone hook and an <Dropzone> component for file drag-and-drop upload zones. It abstracts the HTML5 File API drag events, handles click-to-browse, validates MIME types and file sizes, and provides getRootProps / getInputProps for rendering your own UI.
When to use it
Use React Dropzone when users need to upload files - images, documents, CSVs. It integrates with React Hook Form via Controller and pairs well with direct-to-S3 uploads or presigned URL patterns.
Why use it instead of a raw file input?
You can absolutely build an upload zone with a plain <input type="file"> and a few drag event listeners. People do it, and then they discover the long tail of edge cases. Drag events fire on child elements and bubble in confusing ways. The native input is famously hard to style. Detecting whether the user is dragging a valid file versus dragging text from another tab takes real effort. React Dropzone has already solved all of that and ships it in roughly 11kB.
What you actually get is a headless hook. It doesn't render a single styled element on its own. You spread getRootProps() on your container and getInputProps() on a hidden input, and the library wires up the events, the keyboard handling, and the click-to-browse fallback. Everything visible is your markup, so it fits whatever design system you already use. That's why the same library shows up in apps that look nothing alike.
Validation and the rejection flow
The part people underuse is the rejection handling. When a file fails a check, it doesn't silently disappear. It lands in fileRejections, an array where each entry carries the file and an errors list with stable codes like file-too-large, file-invalid-type, and too-many-files. Those codes are the reliable thing to branch on, not the human-readable messages, which can change.
A pattern I reach for on every upload form: render accepted files in one list and rejected files in another, each rejected item showing exactly why it failed. Users hate a dropzone that just refuses their file with no explanation. Surfacing "that PDF is 12MB, the limit is 5MB" turns a frustrating dead end into something they can fix in one try.
const { fileRejections } = useDropzone({ maxSize: 5 * 1024 * 1024 })
{fileRejections.map(({ file, errors }) => (
<p key={file.name} className="text-red-600 text-sm">
{file.name}: {errors.map(e => e.message).join(', ')}
</p>
))}
Keep in mind that accept filtering happens at the browser level for the file picker, but the drop validation is what truly enforces your rules. A determined user can drag in anything, so always validate again on the server. Client-side checks are for fast feedback, not security.
Accessibility and memory notes
The hook handles keyboard access for you. The root element becomes focusable and responds to Enter and Space to open the picker, which means keyboard-only users aren't locked out. If you build your own zone from scratch, this is the bit most people forget. Do add a visible focus style and a real <label> or aria-label describing the action, since "drag and drop here" doesn't translate to assistive tech on its own.
The memory leak trap is worth repeating because it bites everyone once. When you generate image previews with URL.createObjectURL(file), each call allocates a blob URL that the browser holds until you free it. Store the URLs in state, and in a cleanup effect call URL.revokeObjectURL(url) for each one when the component unmounts or the file list changes. Skip this and a heavy upload page slowly eats memory across a session, which is the kind of bug that never shows up in a quick demo but does in production.
Key Features
useDropzonehook - headless; you control the rendering- MIME type filtering -
acceptprop with MIME type map - File size limits -
maxSize,minSizein bytes - Multiple files -
multipleprop;maxFileslimit - Drag-active state -
isDragActive,isDragRejectfor visual feedback - Programmatic open -
open()method to trigger file picker without a click
Installation
npm install react-dropzone
Basic Usage
import { useDropzone } from 'react-dropzone'
import { useCallback } from 'react'
function FileUpload() {
const onDrop = useCallback((acceptedFiles: File[]) => {
// Do something with the files
acceptedFiles.forEach(file => console.log(file.name, file.size))
}, [])
const { getRootProps, getInputProps, isDragActive, isDragReject, acceptedFiles } = useDropzone({
onDrop,
accept: { 'image/*': ['.png', '.jpg', '.webp'], 'application/pdf': ['.pdf'] },
maxSize: 5 * 1024 * 1024, // 5MB
multiple: true,
maxFiles: 5,
})
return (
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
isDragReject ? 'border-red-400 bg-red-50'
: isDragActive ? 'border-indigo-400 bg-indigo-50'
: 'border-slate-300 hover:border-indigo-400'
}`}
>
<input {...getInputProps()} />
<p className="text-slate-500">
{isDragReject ? 'File type not accepted'
: isDragActive ? 'Drop files here...'
: 'Drag files here, or click to browse'}
</p>
{acceptedFiles.length > 0 && (
<ul className="mt-4 text-left text-sm">
{acceptedFiles.map(f => <li key={f.name}>{f.name} ({(f.size / 1024).toFixed(1)}kB)</li>)}
</ul>
)}
</div>
)
}
Integration with React Hook Form
import { Controller } from 'react-hook-form'
import { useDropzone } from 'react-dropzone'
function FileField({ name, control }: { name: string; control: Control }) {
return (
<Controller
name={name}
control={control}
render={({ field }) => {
const { getRootProps, getInputProps } = useDropzone({
onDrop: (files) => field.onChange(files),
multiple: false,
})
return (
<div {...getRootProps()} className="border-2 border-dashed rounded-lg p-6 text-center cursor-pointer">
<input {...getInputProps()} />
{field.value?.[0]?.name ?? 'Drop a file or click to select'}
</div>
)
}}
/>
)
}
TypeScript Tips
Type accepted files explicitly:
const { acceptedFiles, fileRejections } = useDropzone({
accept: { 'text/csv': ['.csv'] },
})
// fileRejections is typed as FileRejection[]
fileRejections.forEach(({ file, errors }) => {
errors.forEach(e => console.error(e.code, e.message))
})
Common Gotchas
acceptchanged in v12 - the old string format ('image/*') was replaced by a MIME type object ({ 'image/*': ['.jpg'] }). The new format is more explicit about allowed extensionsonDropfires for rejected files too - useonDropAcceptedandonDropRejectedseparately if you need to handle them differently- Preview URLs - generate with
URL.createObjectURL(file)and revoke withURL.revokeObjectURL()in a cleanup effect to avoid memory leaks
Related
- React Hook Form - wrap React Dropzone with
<Controller>to add file upload to validated forms - React Select - another complex input that pairs with React Hook Form via
<Controller>