React Drag & Drop Done Right with useDropZone
Build accessible file uploads with a drag and drop hook that handles nested elements, multiple files, and SSR correctly.
Build a Better React Drag & Drop Zone with useDropZone
File uploads look simple until you try to build one yourself.
Most React developers start with a few drag events, a boolean state, and some styling. Everything works until the drop area contains an icon, a button, or a preview image. Suddenly the hover state begins flashing every time the cursor moves between child elements.
The problem is surprisingly common. Fortunately, it also has a clean solution.
In this guide you’ll learn why traditional drag and drop implementations fail, how useDropZone solves the issue, and how to build an uploader that is reliable, accessible, and easy to maintain.
The Naive Approach
A first implementation usually looks like this:
import { useState } from "react";
export function DropZone() {
const [isOver, setIsOver] = useState(false);
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
console.log(files);
setIsOver(false);
};
return (
<div
onDragEnter={() => setIsOver(true)}
onDragLeave={() => setIsOver(false)}
onDragOver={(event) => event.preventDefault()}
onDrop={handleDrop}
className={isOver ? "dropzone active" : "dropzone"}
>
<h3>Drop files here</h3>
<p>or click to upload</p>
</div>
);
}At first glance everything seems fine.
As soon as the drop zone contains nested elements, however, the border starts blinking while the file is dragged across the content.
The issue is not React. It is how drag events work in the browser.
Why the Hover State Flickers
dragenter and dragleave fire on every element the cursor enters.
Imagine this structure:
Drop Zone
├── Icon
├── Title
└── Preview ImageWhile dragging a file, the pointer moves like this:
Container
↓
Icon
↓
Container
↓
PreviewEach transition generates another dragenter or dragleave.
If your code simply toggles a boolean, it quickly becomes:
true
false
true
false
trueThe pointer never left the drop zone.
It only crossed child elements.
Two Other Common Mistakes
Forgetting preventDefault()
Without calling preventDefault() during dragover, browsers refuse the drop.
const handleDragOver = (event: React.DragEvent) => {
event.preventDefault();
};Miss this one line and onDrop will never execute.
Treating FileList Like an Array
event.dataTransfer.files is a FileList.
It does not support array methods.
Instead of this:
event.dataTransfer.files.map(...)convert it first:
const files = Array.from(event.dataTransfer.files);A Cleaner Solution with useDropZone
@reactuses/core provides a hook that hides all of this complexity.
import { useRef } from "react";
import { useDropZone } from "@reactuses/core";
export function DropZone() {
const ref = useRef<HTMLDivElement>(null);
const isOver = useDropZone(ref, (files) => {
if (!files) return;
console.log(files);
});
return (
<div
ref={ref}
className={isOver ? "dropzone active" : "dropzone"}
>
<h3>Drop files here</h3>
<p>Images, PDFs, or documents</p>
</div>
);
}The hook returns a simple boolean indicating whether the pointer is currently over the drop zone.
Internally it takes care of browser quirks, event cleanup, and nested elements.
Hook API
function useDropZone(
target: BasicTarget<EventTarget>,
onDrop?: (files: File[] | null) => void
): boolean;The callback receives either:
File[]or
nullnull means the drag operation did not contain files, such as dragging selected text or a URL.
How useDropZone Eliminates Flickering
Instead of relying on a boolean, the hook tracks how many active drag targets exist.
A simplified version looks like this:
const counter = useRef(0);
useEventListener("dragenter", (event) => {
event.preventDefault();
counter.current++;
setIsOver(true);
}, target);
useEventListener("dragleave", (event) => {
event.preventDefault();
counter.current--;
if (counter.current === 0) {
setIsOver(false);
}
}, target);
useEventListener("drop", (event: DragEvent) => {
event.preventDefault();
counter.current = 0;
setIsOver(false);
const files = Array.from(event.dataTransfer?.files ?? []);
onDrop?.(files.length ? files : null);
}, target);Every dragenter increments the counter.
Every dragleave decrements it.
The hover state only disappears when the counter reaches zero, which only happens after the pointer leaves the entire drop zone, not when it moves between child elements.
The result is a perfectly stable hover effect.
Building an Accessible Uploader
Drag and drop works well with a mouse.
Keyboard users cannot drag files from the desktop.
A good uploader always provides another way to choose files.
useFileDialog makes this easy.
import { useRef } from "react";
import {
useDropZone,
useFileDialog
} from "@reactuses/core";
export function ImageUploader() {
const ref = useRef<HTMLDivElement>(null);
const isOver = useDropZone(ref, handleFiles);
const [selectedFiles, openDialog] = useFileDialog({
accept: "image/*",
multiple: true
});
function handleFiles(files: File[] | null) {
if (!files) return;
console.log(files);
}
return (
<div
ref={ref}
className={isOver ? "dropzone active" : "dropzone"}
>
<p>Drop images here</p>
<button onClick={openDialog}>
Browse Files
</button>
</div>
);
}Users can either drag files or activate the button using the keyboard.
Both paths produce the same upload workflow.
Real World Applications
Image Galleries
Allow users to drag dozens of photos into a gallery while generating previews immediately.
const previews = files.map((file) => ({
file,
url: URL.createObjectURL(file)
}));No upload is required before showing thumbnails.
Content Management Systems
Editors often need dedicated upload targets.
Examples include:
Hero image
Gallery images
Attachments
PDFs
Videos
Each area can have its own useDropZone instance with an independent hover state.
Document Management
Business dashboards frequently support dropping invoices, contracts, spreadsheets, or ZIP archives directly into the application.
The hook works exactly the same regardless of file type.
Media Libraries
Creative tools often allow users to drag hundreds of assets at once.
Since the hook already returns a standard File[], processing batches becomes straightforward.
for (const file of files) {
upload(file);
}SSR Safety
Some DOM hooks fail during server-side rendering because they immediately access window or document.
useDropZone avoids this problem.
Event listeners are attached only after the component mounts in the browser.
During SSR the hook simply returns:
falseNo hydration mismatches.
No conditional rendering.
No typeof window !== "undefined" checks.
Best Practices
A few small improvements make upload components much more reliable.
Always call
preventDefault()during drag events.Convert
FileListintoFile[]immediately.Never rely on a simple boolean for nested drag areas.
Offer both drag and click interactions.
Validate file types before uploading.
Limit maximum file size.
Revoke preview URLs after use.
URL.revokeObjectURL(previewUrl);Cleaning up object URLs prevents unnecessary memory usage in long-running applications.
Final Thoughts
Building a polished drag and drop uploader is more complicated than wiring up four browser events.
Nested elements, bubbling, browser defaults, accessibility, and server rendering all introduce subtle edge cases that are easy to overlook.
useDropZone hides those implementation details behind a small, predictable API. It provides a stable hover state, automatically converts FileList into File[], and works cleanly alongside useFileDialog to create upload components that everyone can use.
Instead of debugging flickering borders and inconsistent drag events, you can focus on building the upload experience itself.


