useDropArea turns any element into a drop zone — a region that accepts files, text, or links dragged in from outside the page and reports whether a drag is currently hovering it. You have used one every time you dragged a photo onto a chat box or a file onto an upload area. Native HTML5 drag-and-drop has two gotchas that make a naive drop zone silently fail, and this hook exists to get them right. You will implement it in the shape popularized by react-use: call it, get back a bond of event handlers to spread on your element plus a small state object.
function useDropArea(options?: {
onFiles?: (files: File[], event: DragEvent) => void; // dropped files
onText?: (text: string, event: DragEvent) => void; // dropped plain text
onUri?: (uri: string, event: DragEvent) => void; // a dropped link
}): [
bond: { // spread onto the drop element
onDragOver: (e) => void;
onDragEnter: (e) => void;
onDragLeave: (e) => void;
onDrop: (e) => void;
},
state: { over: boolean }, // true while a drag is over the element
];
function Uploader() {
const [bond, { over }] = useDropArea({
onFiles: (files) => console.log(files.map((f) => f.name)),
onUri: (url) => console.log('link:', url),
});
return (
<div {...bond} className={over ? 'zone hovering' : 'zone'}>
Drop files here
</div>
);
}
// What each drop routes to:
// drag 2 files from the desktop -> onFiles([File, File], event)
// drag a link from another browser -> onUri('https://example.com', event)
// drag a selection of text -> onText('the selected words', event)
// drag over a child icon inside zone -> over stays true (no flicker)
dragover must call event.preventDefault() or the browser rejects the drop and onDrop never fires. This is the first thing to get right.dragleave fires when the cursor crosses onto a child of the zone, so a plain boolean flips off mid-drag. Keep over steady while the drag is anywhere inside.event.dataTransfer and call the matching callback: files from dataTransfer.files, a link from getData('text/uri-list'), text from getData('text'). Decide the priority when more than one is present.over looks.onFiles every render. A drop should call the latest one without re-wiring the handlers each render.DataTransfer; drive drops with a mock object exposing files, types, and getData(type).You will hand back a bond of four drag handlers to spread on an element, track whether a drag is over it, and on drop route the payload to the right callback — with two HTML5 drag-and-drop rules that are the whole reason a naive drop zone does not work.
A drop zone is any element that accepts files, text, or links dragged in from outside the page. It sounds like one drop listener: read the files, done. But the browser will not even deliver that drop unless you first tell it the element is a valid target, and the "is a drag hovering me" highlight — the thing every drop zone shows — strobes on and off the moment the cursor passes an icon inside the zone. Both are famous gotchas. useDropArea packages the correct handling into a hook shaped like react-use's: call it, get [bond, state], spread bond on your element, read state.over.
The bond is four handlers — onDragOver, onDragEnter, onDragLeave, onDrop — that you spread onto an element. The non-obvious rule is the first one. A dragover fires continuously while a drag hovers an element, and the browser's default action for it is "this element is not a drop target." So unless onDragOver calls preventDefault, the browser cancels the drop and your onDrop never runs. It is the single most common reason a drop zone silently does nothing.
The obvious version tracks over as a boolean and dispatches the drop:
const { useState, useMemo } = require('react');
function useDropArea(options = {}) {
const { onFiles } = options;
const [over, setOver] = useState(false);
const bond = useMemo(() => ({
onDragOver() {}, // nothing here
onDragEnter(e) { e.preventDefault(); setOver(true); },
onDragLeave() { setOver(false); }, // boolean
onDrop(e) {
e.preventDefault();
setOver(false);
if (onFiles && e.dataTransfer.files.length) onFiles(Array.from(e.dataTransfer.files), e);
},
}), [onFiles]);
return [bond, { over }];
}
Two things break it. onDragOver is empty, so in a real browser the drop is rejected and onDrop never fires. And over is a boolean: dragleave also fires when the pointer crosses onto a child of the zone (an icon, a label), so the highlight flips off mid-drag. react-use's own useDropArea ships exactly this boolean, and it flickers.
const { useState, useRef, useMemo } = require('react');
function useDropArea(options = {}) {
// Callers pass fresh inline callbacks every render. Read the latest options
// from a ref at drop time so the bond never has to be rebuilt — that is what
// keeps the handler identities stable across renders.
const optionsRef = useRef(options);
optionsRef.current = options;
const [over, setOver] = useState(false);
// A depth counter, NOT a boolean. dragenter/dragleave also fire for the zone's
// descendants, so crossing onto a child would flip a boolean to false mid-drag.
// Counting depth only reaches 0 once the drag has left the zone AND its children.
const depth = useRef(0);
const bond = useMemo(
() => ({
onDragOver(event) {
// The line that makes the element a drop target at all. The browser's
// default for dragover is "not droppable"; without this the drop is
// rejected and onDrop below never fires.
event.preventDefault();
},
onDragEnter(event) {
event.preventDefault();
depth.current += 1;
setOver(true);
},
onDragLeave() {
depth.current -= 1;
if (depth.current <= 0) {
depth.current = 0;
setOver(false);
}
},
onDrop(event) {
event.preventDefault();
depth.current = 0; // a drop fires no dragleave — reset by hand
setOver(false);
const dataTransfer = event.dataTransfer;
if (!dataTransfer) return;
const opts = optionsRef.current;
// First match wins, most-specific first: a dragged link, then files,
// then plain text.
const uri = dataTransfer.getData('text/uri-list');
if (uri) {
if (opts.onUri) opts.onUri(uri, event);
return;
}
if (dataTransfer.files && dataTransfer.files.length) {
if (opts.onFiles) opts.onFiles(Array.from(dataTransfer.files), event);
return;
}
const text = dataTransfer.getData('text');
if (text) {
if (opts.onText) opts.onText(text, event);
}
},
}),
[],
);
return [bond, { over }];
}
module.exports = { useDropArea };
Three shifts from the naive version. onDragOver now calls preventDefault, so the drop actually arrives. over is driven by depth instead of a boolean, so a child no longer flips it off. And the callbacks are read from optionsRef at drop time, so the bond is built once (useMemo with an empty dependency list) yet always calls the current onFiles.
Here is the sequence that breaks the boolean. Move a drag from the zone's own area onto a child element inside it: the browser fires dragenter on the child (which bubbles up to the zone) and then dragleave on the zone — because as far as the zone is concerned, the pointer left it. A boolean set false on that dragleave reads not-over while the cursor is still very much inside. Counting fixes it: +1 on every dragenter, -1 on every dragleave, and over = depth > 0. The child bumps the count to 2, the zone's leave drops it to 1, and over never touches false.
The other well-known fix is to record the element that got the last dragenter and only treat a dragleave as real when it lands on that same element — the approach ahooks' useDrop takes. The counter is the classic one and it reads cleanly. Either beats the boolean.
On drop, the payload lives on event.dataTransfer, and you dispatch by what it holds, checking the most specific type first: a link (getData('text/uri-list')), then real files (dataTransfer.files), then plain text (getData('text')). Order matters because a link dragged from another tab carries both a url and its text, so checking the uri first routes it to onUri instead of onText. dataTransfer.files is a FileList, not an array, so Array.from gives the callback something with .map.
A user drags a photo off the desktop onto a zone wired with useDropArea({ onFiles }).
onDragEnter runs, preventDefaults, bumps depth to 1, sets over to true. Your zone highlights.dragenter fires on the caption (depth → 2), then dragleave on the zone (depth → 1). depth is still positive, so over stays true — no flicker.dragover fires the whole time. Each one calls preventDefault, which is what keeps the element a valid drop target so the next step can happen at all.onDrop runs: preventDefault, depth reset to 0, over back to false. getData('text/uri-list') is empty (a real file carries no uri), dataTransfer.files.length is 1, so onFiles([File], event) fires with the photo.onDragOver. No preventDefault on dragover means the browser rejects the drop and onDrop never fires — the drop zone looks completely dead. This is the number-one drop-zone bug.over. Set true on enter and false on leave, it strobes every time the cursor crosses a child element. Count depth instead, or track the enter target.dragleave, so if you only zero the counter in onDragLeave, it stays positive and the next drag starts already "over." Reset depth and over in onDrop too.FileList. dataTransfer.files has no array methods; Array.from it before handing it to onFiles.bond depends on the caller's inline callbacks, spreading it re-attaches handlers constantly. Read callbacks from a ref and memoize the bond once.paste event's clipboardData, which has the same getData/files shape — react-use adds an onPaste to the bond so a pasted image lands in the same handler as a dropped one.dataTransfer.items; item.webkitGetAsEntry() exposes a FileSystemEntry you can walk recursively to read a whole tree. It is non-standard but widely supported.dragover you can read dataTransfer.items[i].type (the MIME type is available before drop) to show a "not allowed" cursor, and re-check after drop.effectAllowed. dataTransfer.dropEffect and effectAllowed control the cursor badge (copy, move, link), and setDragImage customizes the ghost — relevant when your element is also a drag source, not only a target.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
useDropArea turns any element into a drop zone — a region that accepts files, text, or links dragged in from outside the page and reports whether a drag is currently hovering it. You have used one every time you dragged a photo onto a chat box or a file onto an upload area. Native HTML5 drag-and-drop has two gotchas that make a naive drop zone silently fail, and this hook exists to get them right. You will implement it in the shape popularized by react-use: call it, get back a bond of event handlers to spread on your element plus a small state object.
function useDropArea(options?: {
onFiles?: (files: File[], event: DragEvent) => void; // dropped files
onText?: (text: string, event: DragEvent) => void; // dropped plain text
onUri?: (uri: string, event: DragEvent) => void; // a dropped link
}): [
bond: { // spread onto the drop element
onDragOver: (e) => void;
onDragEnter: (e) => void;
onDragLeave: (e) => void;
onDrop: (e) => void;
},
state: { over: boolean }, // true while a drag is over the element
];
function Uploader() {
const [bond, { over }] = useDropArea({
onFiles: (files) => console.log(files.map((f) => f.name)),
onUri: (url) => console.log('link:', url),
});
return (
<div {...bond} className={over ? 'zone hovering' : 'zone'}>
Drop files here
</div>
);
}
// What each drop routes to:
// drag 2 files from the desktop -> onFiles([File, File], event)
// drag a link from another browser -> onUri('https://example.com', event)
// drag a selection of text -> onText('the selected words', event)
// drag over a child icon inside zone -> over stays true (no flicker)
dragover must call event.preventDefault() or the browser rejects the drop and onDrop never fires. This is the first thing to get right.dragleave fires when the cursor crosses onto a child of the zone, so a plain boolean flips off mid-drag. Keep over steady while the drag is anywhere inside.event.dataTransfer and call the matching callback: files from dataTransfer.files, a link from getData('text/uri-list'), text from getData('text'). Decide the priority when more than one is present.over looks.onFiles every render. A drop should call the latest one without re-wiring the handlers each render.DataTransfer; drive drops with a mock object exposing files, types, and getData(type).You will hand back a bond of four drag handlers to spread on an element, track whether a drag is over it, and on drop route the payload to the right callback — with two HTML5 drag-and-drop rules that are the whole reason a naive drop zone does not work.
A drop zone is any element that accepts files, text, or links dragged in from outside the page. It sounds like one drop listener: read the files, done. But the browser will not even deliver that drop unless you first tell it the element is a valid target, and the "is a drag hovering me" highlight — the thing every drop zone shows — strobes on and off the moment the cursor passes an icon inside the zone. Both are famous gotchas. useDropArea packages the correct handling into a hook shaped like react-use's: call it, get [bond, state], spread bond on your element, read state.over.
The bond is four handlers — onDragOver, onDragEnter, onDragLeave, onDrop — that you spread onto an element. The non-obvious rule is the first one. A dragover fires continuously while a drag hovers an element, and the browser's default action for it is "this element is not a drop target." So unless onDragOver calls preventDefault, the browser cancels the drop and your onDrop never runs. It is the single most common reason a drop zone silently does nothing.
The obvious version tracks over as a boolean and dispatches the drop:
const { useState, useMemo } = require('react');
function useDropArea(options = {}) {
const { onFiles } = options;
const [over, setOver] = useState(false);
const bond = useMemo(() => ({
onDragOver() {}, // nothing here
onDragEnter(e) { e.preventDefault(); setOver(true); },
onDragLeave() { setOver(false); }, // boolean
onDrop(e) {
e.preventDefault();
setOver(false);
if (onFiles && e.dataTransfer.files.length) onFiles(Array.from(e.dataTransfer.files), e);
},
}), [onFiles]);
return [bond, { over }];
}
Two things break it. onDragOver is empty, so in a real browser the drop is rejected and onDrop never fires. And over is a boolean: dragleave also fires when the pointer crosses onto a child of the zone (an icon, a label), so the highlight flips off mid-drag. react-use's own useDropArea ships exactly this boolean, and it flickers.
const { useState, useRef, useMemo } = require('react');
function useDropArea(options = {}) {
// Callers pass fresh inline callbacks every render. Read the latest options
// from a ref at drop time so the bond never has to be rebuilt — that is what
// keeps the handler identities stable across renders.
const optionsRef = useRef(options);
optionsRef.current = options;
const [over, setOver] = useState(false);
// A depth counter, NOT a boolean. dragenter/dragleave also fire for the zone's
// descendants, so crossing onto a child would flip a boolean to false mid-drag.
// Counting depth only reaches 0 once the drag has left the zone AND its children.
const depth = useRef(0);
const bond = useMemo(
() => ({
onDragOver(event) {
// The line that makes the element a drop target at all. The browser's
// default for dragover is "not droppable"; without this the drop is
// rejected and onDrop below never fires.
event.preventDefault();
},
onDragEnter(event) {
event.preventDefault();
depth.current += 1;
setOver(true);
},
onDragLeave() {
depth.current -= 1;
if (depth.current <= 0) {
depth.current = 0;
setOver(false);
}
},
onDrop(event) {
event.preventDefault();
depth.current = 0; // a drop fires no dragleave — reset by hand
setOver(false);
const dataTransfer = event.dataTransfer;
if (!dataTransfer) return;
const opts = optionsRef.current;
// First match wins, most-specific first: a dragged link, then files,
// then plain text.
const uri = dataTransfer.getData('text/uri-list');
if (uri) {
if (opts.onUri) opts.onUri(uri, event);
return;
}
if (dataTransfer.files && dataTransfer.files.length) {
if (opts.onFiles) opts.onFiles(Array.from(dataTransfer.files), event);
return;
}
const text = dataTransfer.getData('text');
if (text) {
if (opts.onText) opts.onText(text, event);
}
},
}),
[],
);
return [bond, { over }];
}
module.exports = { useDropArea };
Three shifts from the naive version. onDragOver now calls preventDefault, so the drop actually arrives. over is driven by depth instead of a boolean, so a child no longer flips it off. And the callbacks are read from optionsRef at drop time, so the bond is built once (useMemo with an empty dependency list) yet always calls the current onFiles.
Here is the sequence that breaks the boolean. Move a drag from the zone's own area onto a child element inside it: the browser fires dragenter on the child (which bubbles up to the zone) and then dragleave on the zone — because as far as the zone is concerned, the pointer left it. A boolean set false on that dragleave reads not-over while the cursor is still very much inside. Counting fixes it: +1 on every dragenter, -1 on every dragleave, and over = depth > 0. The child bumps the count to 2, the zone's leave drops it to 1, and over never touches false.
The other well-known fix is to record the element that got the last dragenter and only treat a dragleave as real when it lands on that same element — the approach ahooks' useDrop takes. The counter is the classic one and it reads cleanly. Either beats the boolean.
On drop, the payload lives on event.dataTransfer, and you dispatch by what it holds, checking the most specific type first: a link (getData('text/uri-list')), then real files (dataTransfer.files), then plain text (getData('text')). Order matters because a link dragged from another tab carries both a url and its text, so checking the uri first routes it to onUri instead of onText. dataTransfer.files is a FileList, not an array, so Array.from gives the callback something with .map.
A user drags a photo off the desktop onto a zone wired with useDropArea({ onFiles }).
onDragEnter runs, preventDefaults, bumps depth to 1, sets over to true. Your zone highlights.dragenter fires on the caption (depth → 2), then dragleave on the zone (depth → 1). depth is still positive, so over stays true — no flicker.dragover fires the whole time. Each one calls preventDefault, which is what keeps the element a valid drop target so the next step can happen at all.onDrop runs: preventDefault, depth reset to 0, over back to false. getData('text/uri-list') is empty (a real file carries no uri), dataTransfer.files.length is 1, so onFiles([File], event) fires with the photo.onDragOver. No preventDefault on dragover means the browser rejects the drop and onDrop never fires — the drop zone looks completely dead. This is the number-one drop-zone bug.over. Set true on enter and false on leave, it strobes every time the cursor crosses a child element. Count depth instead, or track the enter target.dragleave, so if you only zero the counter in onDragLeave, it stays positive and the next drag starts already "over." Reset depth and over in onDrop too.FileList. dataTransfer.files has no array methods; Array.from it before handing it to onFiles.bond depends on the caller's inline callbacks, spreading it re-attaches handlers constantly. Read callbacks from a ref and memoize the bond once.paste event's clipboardData, which has the same getData/files shape — react-use adds an onPaste to the bond so a pasted image lands in the same handler as a dropped one.dataTransfer.items; item.webkitGetAsEntry() exposes a FileSystemEntry you can walk recursively to read a whole tree. It is non-standard but widely supported.dragover you can read dataTransfer.items[i].type (the MIME type is available before drop) to show a "not allowed" cursor, and re-check after drop.effectAllowed. dataTransfer.dropEffect and effectAllowed control the cursor badge (copy, move, link), and setDragImage customizes the ghost — relevant when your element is also a drag source, not only a target.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.