React useDropZone Hook: Build a File Drop Zone (2026)
Drag a file over a drop zone that has any child element inside it — an icon, a label, a preview thumbnail — and a hand-rolled isOver toggle starts flickering: true, false, true, false, as the cursor crosses from the container onto the child and back. The highlight border blinks the whole time the file is over the zone. The bug isn’t in your event handler logic; it’s in what dragenter and dragleave actually fire on.
useDropZone from @reactuses/core turns a DOM element into a file drop target with none of that flicker, plus a callback that hands you the dropped File[] directly. The internals are short enough to read in one sitting, so this post walks through the real bug it fixes and the real API, TypeScript-first.
The Manual Version, and Where It Frays
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>
);
}
Enter fullscreen mode Exit fullscreen mode
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: dragenter on the div, dragenter on the span (bubbles to the div — no-op for this handler), dragleave on the div as the pointer crosses onto the span, then dragenter on 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>
);
}
Enter fullscreen mode Exit fullscreen mode
The signature:
function useDropZone(
target: BasicTarget<EventTarget>,
onDrop?: (files: File[] | null) => void
): boolean;
Enter fullscreen mode Exit fullscreen mode
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);
Enter fullscreen mode Exit fullscreen mode
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
0unconditionally. Even if the browser’sdragenter/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>
);
}
Enter fullscreen mode Exit fullscreen mode
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 previewblob: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.
답글 남기기