React useDropZone Hook: Build a File Drop Zone (2026)
The obvious first attempt pairs dragenter / dragleave with a boolean:
function DropZone() {
const [isOver, setIsOver] = useState(false);
return (
<div
onDragEnter={() => setIsOver(true)}
onDragLeave={() => setIsOver(false)}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
setIsOver(false);
console.log(e.dataTransfer.files);
}}
style={{ border: isOver ? "2px solid blue" : "2px dashed gray" }}
>
<span>Drop files here</span>
</div>
);
}
It looks right, and it's wrong the moment the zone has a child node. Here's the part that isn't obvious: dragenter and dragleave fire on whatever element the pointer is over, and they bubble. Drag a file across the <span> inside the div above, and the browser fires, in order:
dragenteron the divdragenteron the span (bubbles to the div - no-op for this handler)dragleaveon the div as the pointer crosses onto the spandragenteron the span again as it re-enters
Your setIsOver(false) on that middle dragleave fires even though the cursor never left the drop zone - it just crossed a child boundary. The border flickers for as long as the file hovers over any nested content.
Two more things people get wrong the first time:
- Skip
preventDefault()ondragover, anddropnever fires at all. The browser's default action fordragoveris "reject the drop" - nopreventDefault, nodropevent, full stop. - The dropped payload isn't a plain array.
event.dataTransfer.filesis aFileList, not aFile[]- no.map, no.filter, until you convert it.
useDropZone - Fixing the Flicker with a Counter
import { useRef } from 'react';
import { useDropZone } from '@reactuses/core';
function DropZone() {
const ref = useRef<HTMLDivElement>(null);
const isOver = useDropZone(ref, (files) => {
console.log(files); // File[] | null
});
return (
<div
ref={ref}
style={{ border: isOver ? "2px solid blue" : "2px dashed gray" }}
>
<span>Drop files here</span>
</div>
);
}
The signature:
function useDropZone(
target: BasicTarget<EventTarget>,
onDrop?: (files: File[] | null) => void
): boolean;
The fix for the nesting bug is a plain integer, not a debounce or a relatedTarget check - the actual implementation is short enough to quote in full:
const counter = useRef(0);
useEventListener('dragenter', (e) => {
e.preventDefault();
counter.current += 1;
setOver(true);
}, target);
useEventListener('dragleave', (e) => {
e.preventDefault();
counter.current -= 1;
if (counter.current === 0) setOver(false);
}, target);
useEventListener('drop', (e: DragEvent) => {
e.preventDefault();
counter.current = 0;
setOver(false);
const files = Array.from(e.dataTransfer?.files ?? []);
onDrop?.(files.length === 0 ? null : files);
}, target);
Every dragenter - container or child - increments the counter; every dragleave decrements it. isOver only flips back to false when the counter returns to zero, which happens exactly when the pointer has left every element in the subtree, container included. Entering a child increments then immediately re-increments (net positive), so the count never dips to zero mid-hover. It's the same technique used to solve the identical bubbling problem for mouseenter / mouseleave chains, applied to drag events.
preventDefault() is already called for you on all four events, so drop fires reliably and the browser never tries to navigate to a dropped file.
What onDrop Actually Gives You
targetaccepts anyEventTarget, not justHTMLElement- it's a ref, wired up through the sameuseEventListenerthe rest of the library's DOM hooks use, so listeners attach and detach with the component lifecycle automatically.- The callback receives
File[], already converted from the rawFileList- noArray.fromneeded at the call site. - Dropping something that isn't files - a link, selected text - calls back with
null, not an empty array. Check fornullbefore assumingfiles[0]exists. - A drop resets the counter to 0 unconditionally. Even if the browser's
dragenter/dragleavebookkeeping somehow drifted, every completed drop starts the next hover cycle clean.
Pairing with useFileDialog: The Accessibility Gap
Drag-and-drop has a real gap: it's mouse- and touch-only. There's no keyboard equivalent to "drag a file from the desktop," so a drop zone that's only a drop zone is unusable for keyboard and screen-reader users. The fix is a visible "or browse files" fallback, and useFileDialog is built for exactly that pairing - it opens the native file picker without a hidden <input>:
import { useRef } from 'react';
import { useDropZone, useFileDialog } from '@reactuses/core';
function Uploader({ onFiles }: { onFiles: (files: File[]) => void }) {
const ref = useRef<HTMLDivElement>(null);
const isOver = useDropZone(ref, (files) => files && onFiles(files));
const [dialogFiles, open] = useFileDialog({ accept: 'image/*' });
return (
<div
ref={ref}
style={{ border: isOver ? "2px solid blue" : "2px dashed gray" }}
>
<p>Drag files here, or</p>
<button onClick={() => open()}>browse files</button>
</div>
);
}
Same visual drop target, plus a real <button> that a keyboard user can tab to and activate - two paths into the same upload flow.
Real Use Cases
- Custom-styled upload widgets. The default
<input type="file">can't be restyled in most browsers; auseDropZone+useFileDialogpair gives full control over the drop target's appearance while keeping both drag and click working. - Image galleries and media managers. Drop a batch of images onto a gallery grid, feed each
FileintouseObjectUrlto get an instant preview blob URL before any upload request completes. - CMS and form-builder drop targets. Editors that accept dragged assets (a hero image field, a document attachment slot) need exactly the flicker-free
isOverstate to render a convincing "drop here" highlight. - Multi-zone uploaders. Because
targetis just a ref, the same hook instantiated against different refs gives each zone (e.g., "cover image" vs. "gallery images" in one form) independent, correctly isolated hover state.
SSR Safety
useDropZone never touches document or window at the top level - all four listeners are attached through useEventListener, which only runs inside an effect, after the component has mounted on the client. On the server, isOver simply renders as its initial false, and there's no DOM to attach to yet, so there's no hydration mismatch to guard against - unlike hooks that read a value (a cookie, localStorage) that can differ between server and client. Nothing extra to configure here.
Takeaways
- The nesting flicker is a bubbling problem, not a logic bug - a naive boolean toggle breaks the instant a drop zone has any child element.
useDropZonefixes it with an enter/leave counter that only zeroes out when the pointer has actually left the whole subtree. preventDefault()ondragoveris non-negotiable - skip it anddropnever fires. The hook calls it on all four events for you.onDrophands you a realFile[](ornull) - already converted from the rawFileList, withnulldistinguishing "no files were dropped" from "dropped zero files."- Drag-and-drop alone excludes keyboard users - pair it with
useFileDialogso there's always a clickable, tabbable way into the same upload flow. - No SSR ceremony needed - the hook attaches nothing until the client mounts, so there's no server/client mismatch to design around.
Grab it from @reactuses/core and stop debugging drag-event bubbling by hand.
Comments
No comments yet. Start the discussion.