mrkeyoor.com_
Thu 06 Aug 13:53 UTC
npmWeb Frontendupdated 06 Aug 2026

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.

Verdict

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.

API stability3/5The useDropzone surface has been recognizably the same since v11, but v18, v19 and v20 all landed between 18 July and 2 August 2026, with v19 changing what acceptedFiles contains when a batch exceeds maxFiles and v20 raising the Node floor to 22
Docs5/5react-dropzone.js.org has live editable examples for every option plus a full props table, and the README carries an unusually candid caveats section covering Tauri, Android camera pickers, label double-dialogs and the unreliable cancel callback
Maintenance5/5Pushed 2026-08-02 with 2 open issues (3 including PRs) against 11k stars, and a burst of releases in July 2026 that cleared a backlog of issues opened years earlier such as #1210, #1304 and #1355
Ecosystem5/5About 12.9M downloads a week and the assumed file-drop layer in most React upload tutorials, shadcn-style component kits and admin templates; it also composes with dedicated uploaders rather than competing with them

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
Skip it if

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

PackageRegistryPick it when
uppynpmYou want the whole upload pipeline (progress, resumable tus, S3 multipart, webcam and remote sources) instead of just file selection
filepondnpmYou want a finished, styled upload widget with previews and image editing out of the box and are fine adopting its look
use-file-pickernpmYou only need click-to-browse with file reading and validation, and no drag-and-drop surface at all