react-dropzone
react-dropzone is a headless React hook that turns any element into an HTML5 drag-and-drop file zone. You call useDropzone({onDrop}) and it hands back two prop getters: spread getRootProps() on the container and getInputProps() on a hidden file input, and you now have drag events, click-to-browse, keyboard access, paste-to-upload, and per-file validation against MIME types, extensions, file count and size limits. It gives you no markup and no styles, only boolean state like isDragActive and isDragReject plus the accepted and rejected file lists, so the design is entirely yours. It also does not upload anything: it hands you File objects and stops there.
The default choice for the file-selection layer of a React upload flow, and the July 2026 releases closed most of the long-standing paper cuts around async validators, error messages and blocked pickers. Just budget for writing the upload half yourself, and pin the major rather than trusting a caret while the release train is moving this fast.
Use it if
- You want drag-and-drop plus click-to-browse in a React app but need the markup and styling to match your own design system, since the hook renders nothing
- You need per-file gatekeeping before upload: accept maps of MIME type to extensions, minSize, maxSize, maxFiles, and a custom validator that can now be async for checks like reading image dimensions
- You want the browser edge cases already handled: dragenter and dragleave counting on nested children, keyboard activation, paste of a clipboard screenshot, and a fallback to the native input when showOpenFilePicker is blocked by enterprise policy
- You already have an upload path (a presigned S3 PUT, tRPC mutation, or your own multipart endpoint) and only need the file selection layer in front of it
- You expected an uploader: there is no HTTP request, no progress bar, no retry, no chunking and no resume anywhere in this library, and the README says so directly. Reaching for uppy or filepond will save you a week of writing that yourself
- You need the filesystem path of a dropped file, for example in an Electron or Tauri app. The File objects have no path or fullPath property, and inside a Tauri webview the OS intercepts drops before the webview sees them unless you turn off dragDropEnabled in tauri.conf.json
- You need onFileDialogCancel to be reliable. It is implemented as a 300ms timeout after the window regains focus, so a large selection that the browser is still reading fires a false cancel. The File System Access API path is the accurate one and it only exists in secure contexts on some browsers
- Your toolchain cannot move to Node 22. Version 20.0.0 sets engines.node to >= 22, so installs under engine-strict fail and CI images stuck on Node 20 need pinning to 19.x
- You want a stable version to sit on for a year. Three majors shipped inside three weeks in July and August 2026 (v19 changed how over-limit batches resolve, v20 raised the Node floor), so a caret range can move under you
- You are on React 17 or older, or on a non-React framework. The library is a hook, so React 18 is the hard floor and there is no vanilla, Vue or Svelte build
Setup reality
npm install react-dropzone pulls two small pure-JS dependencies (attr-accept and file-selector) and no native code, and types ship in the package so @types/react is an optional peer. The friction starts after that. React >= 18 is a real peer dependency, and v20 sets engines.node to >= 22, which npm rejects outright under engine-strict and pnpm warns about loudly. If you forget to render an <input> with getInputProps(), clicking does nothing and the file dialog never opens, with no error. Do not put ref on the elements you spread the getters onto; read rootRef and inputRef off the hook instead, and pass extra handlers through getRootProps({onClick}) so yours compose rather than clobber the internal ones. Using a <label> as the root opens the dialog twice unless you set noClick. Every pixel of styling is yours. Tests need @testing-library/react with a hand-built dataTransfer mock, and Enzyme is explicitly unsupported.
Patterns
Minimal drag-and-drop zonebasic-dropzone
import {useCallback} from "react";
import {useDropzone} from "react-dropzone";
export function Uploader() {
const onDrop = useCallback((accepted, rejected) => {
console.log(accepted, rejected);
}, []);
const {getRootProps, getInputProps, isDragActive} = useDropzone({onDrop});
return (
<div {...getRootProps()}>
<input {...getInputProps()} />
<p>{isDragActive ? "Drop them here" : "Drag files here, or click to browse"}</p>
</div>
);
}The <input> is not optional. Leave it out and drag-and-drop still works but clicking never opens the file dialog, silently. Wrap onDrop in useCallback or every render rebuilds the handlers.
Accept only certain MIME types and extensionsrestrict-file-types
const {getRootProps, getInputProps} = useDropzone({
accept: {
"image/png": [".png"],
"image/jpeg": [".jpg", ".jpeg"],
"application/pdf": [".pdf"]
},
onDrop
});accept is a map of MIME type to extensions, not the old array of strings from v11. The extensions are what the OS file dialog filters on; the MIME key is what drag validation checks. Both matter, so fill in both.
Cap file count and sizelimit-count-and-size
const {getRootProps, getInputProps, acceptedFiles, fileRejections} = useDropzone({
maxFiles: 5,
minSize: 1024, // 1 KB
maxSize: 10 * 1024 * 1024, // 10 MB
multiple: true,
onDrop
});Since v19 dropping 8 files with maxFiles: 5 accepts the first 5 and rejects the rest with a too-many-files error. Before v19 the whole batch was rejected, so any code that assumed all-or-nothing needs revisiting.
Render why files were rejectedshow-rejections
import {useDropzone, ErrorCode} from "react-dropzone";
const {getRootProps, getInputProps, fileRejections} = useDropzone({
accept: {"image/*": []},
maxSize: 5_000_000
});
const messages = fileRejections.map(({file, errors}) => (
<li key={file.name}>
{file.name}
{errors.map(e => (
<span key={e.code}>
{e.code === ErrorCode.FileTooLarge ? " is over 5 MB" : ` ${e.message}`}
</span>
))}
</li>
));Switch on e.code, never on e.message. The codes are the ErrorCode enum: file-invalid-type, file-too-large, file-too-small, too-many-files. Messages are English prose and are not a stable API.
Reject files with your own rule, sync or asynccustom-validator
const {getRootProps, getInputProps, isProcessing} = useDropzone({
validator: async file => {
if (file.name.length > 100) {
return {code: "name-too-long", message: "Filename must be under 100 characters"};
}
const bitmap = await createImageBitmap(file);
if (bitmap.width < 800) {
return {code: "too-narrow", message: "Image must be at least 800px wide"};
}
return null;
},
onError: err => console.error(err)
});Async validators landed in v19.1.0. While one is pending, isProcessing is true and onDrop has not fired yet, so gate your submit button on it. A validator never runs during a drag (a DataTransferItem has no size), which is why such a dropzone reports isDragUnknown instead of isDragAccept.
Open the file dialog from your own buttonopen-programmatically
const {getRootProps, getInputProps, open} = useDropzone({
noClick: true,
noKeyboard: true
});
return (
<div {...getRootProps()}>
<input {...getInputProps()} />
<p>Drop files here</p>
<button type="button" onClick={open}>Browse</button>
</div>
);Without noClick the button click bubbles to the root and the dialog opens twice. Also, open() must run in the same synchronous turn as the user gesture: setting state and calling open() in one handler uses the previous render's props, and Safari may refuse to open at all.
Style the zone from the drag state booleansstyle-by-drag-state
const {getRootProps, getInputProps, isDragActive, isDragAccept, isDragReject, isDragUnknown} =
useDropzone({accept: {"image/*": []}});
const className = [
"dropzone",
isDragActive && "is-active",
isDragAccept && "is-accept",
isDragReject && "is-reject",
isDragUnknown && "is-unknown"
]
.filter(Boolean)
.join(" ");
return <div {...getRootProps({className})}><input {...getInputProps()} /></div>;Pass className through getRootProps rather than setting it on the div directly, so it merges instead of being overwritten. isDragUnknown means the browser exposed no usable type during the drag, so do not paint it red.
Show thumbnails and free the object URLsimage-previews
import {useEffect, useState} from "react";
import {useDropzone} from "react-dropzone";
const [previews, setPreviews] = useState([]);
const {getRootProps, getInputProps} = useDropzone({
accept: {"image/*": []},
onDrop: files => setPreviews(files.map(f => ({name: f.name, url: URL.createObjectURL(f)})))
});
useEffect(() => () => previews.forEach(p => URL.revokeObjectURL(p.url)), [previews]);createObjectURL holds the whole file in memory until revoked. Skipping the cleanup is the standard memory leak in dropzone code, and it is easy to miss because nothing breaks visibly.
Actually send the files somewhereupload-the-files
const {getRootProps, getInputProps} = useDropzone({
onDrop: async accepted => {
for (const file of accepted) {
const body = new FormData();
body.append("file", file);
const res = await fetch("/api/upload", {method: "POST", body});
if (!res.ok) console.error(`upload failed: ${file.name}`);
}
}
});This part is yours. fetch gives no upload progress event, so a progress bar means XMLHttpRequest and its upload.onprogress, or a library built for it. That gap is the main reason people move to uppy later.
Localize the rejection messagescustom-error-messages
import {useDropzone, ErrorCode} from "react-dropzone";
const {getRootProps, getInputProps} = useDropzone({
maxSize: 2_000_000,
getErrorMessage: (error, file) => {
switch (error.code) {
case ErrorCode.FileTooLarge:
return `${file.name} is too big (max 2 MB)`;
case ErrorCode.FileInvalidType:
return `${file.name} is not a supported format`;
default:
return error.message;
}
}
});getErrorMessage arrived in v18.2.0 and replaces the old habit of string-matching the built-in messages. It rewrites the message on built-in errors only; errors your own validator returns come through untouched.
Handle or disable pasted screenshotspaste-to-upload
// Enabled by default: a focused dropzone accepts Ctrl/Cmd+V of clipboard files
const pasteable = useDropzone({autoFocus: true, onDrop});
// Opt out when the zone wraps a textarea and you want plain paste behaviour
const noPasting = useDropzone({noPaste: true, onDrop});Paste support is on by default since v19.2.0, and pasted files run through the same accept, size and validator checks. It only fires when the dropzone or a focused child has focus, so pair it with autoFocus or expect users to click in first.
Use showOpenFilePicker with labeled filter groupsfile-system-access-api
const {getRootProps, getInputProps} = useDropzone({
useFsAccessApi: true,
accept: [
{description: "Images", accept: {"image/png": [".png"], "image/jpeg": [".jpg"]}},
{description: "Documents", accept: {"application/pdf": [".pdf"]}}
]
});Grouped accept only shows up in the File System Access picker; the native <input> fallback flattens every group into one accept attribute. The API needs a secure context, cannot select directories, and on a NotAllowedError from enterprise policy the library falls back to the native input, which only exists if you rendered getInputProps().
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| uppy | npm | You want the whole upload pipeline (progress, resumable tus, S3 multipart, webcam and remote sources) instead of just file selection |
| filepond | npm | You want a finished, styled upload widget with previews and image editing out of the box and are fine adopting its look |
| use-file-picker | npm | You only need click-to-browse with file reading and validation, and no drag-and-drop surface at all |