React useDropZone Hook: Build a File Drop Zone (2026)

작성자

카테고리:

← 피드로
DEV Community · reactuse.com · 2026-07-28 개발(SW)

reactuse.com

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() on dragover, and drop never fires at all. The browser’s default action for dragover is “reject the drop” — no preventDefault, no drop event, full stop.
  • The dropped payload isn’t a plain array. event.dataTransfer.files is a FileList, not a File[] — 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

  • target accepts any EventTarget, not just HTMLElement — it’s a ref, wired up through the same useEventListener the 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 raw FileList — no Array.from needed at the call site.
  • Dropping something that isn’t files — a link, selected text — calls back with null, not an empty array. Check for null before assuming files[0] exists.
  • A drop resets the counter to 0 unconditionally. Even if the browser’s dragenter/dragleave bookkeeping 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; a useDropZone + useFileDialog pair 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 File into useObjectUrl to 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 isOver state to render a convincing “drop here” highlight.
  • Multi-zone uploaders. Because target is 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. useDropZone fixes it with an enter/leave counter that only zeroes out when the pointer has actually left the whole subtree.
  • preventDefault() on dragover is non-negotiable — skip it and drop never fires. The hook calls it on all four events for you.
  • onDrop hands you a real File[] (or null) — already converted from the raw FileList, with null distinguishing “no files were dropped” from “dropped zero files.”
  • Drag-and-drop alone excludes keyboard users — pair it with useFileDialog so 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.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다