Building a React File Drop Zone With the Native Drag-and-Drop API

Handling file uploads through drag-and-drop is a common requirement in modern web applications. While there are many libraries that abstract this behavior, the native HTML Drag-and-Drop API is a lightweight alternative that gives you full control over the interaction. In this tutorial, we'll build a reusable React component that accepts file drops, tracks them in state, and displays the list of filenames to the user.

The approach relies on four key events that fire during a drag operation: dragenter, dragleave, dragover, and drop. Each event has a distinct role in managing the user's interaction with a target element. A dragenter event is dispatched when the dragged item crosses into a valid drop target, dragleave fires when it exits that target, dragover fires repeatedly while the item hovers over the target, and drop fires only when the item is actually released.

To turn any HTML element into a drop target, you simply register ondragover and ondrop event handlers. The remaining events, dragenter and dragleave, are used to provide visual feedback and manage the UI state during the drag.

Setting Up the Project

To get started, clone the tutorial repository or create a fresh React project. If you prefer to start from scratch, you can create a new project and replace the contents of App.js with a template component and add the styling from App.css. Once the project is running, create a new file called DragAndDrop.js in the src/ folder with a functional component that returns a div registered with the four event handlers mentioned above.

// Structured as a functional component
// with event handlers for dragenter, dragleave,
// dragover, and drop events

The component's div becomes a valid drop target right away, since the onDragOver and onDrop attributes are present. Each handler receives the event object and calls both preventDefault() to suppress the browser's default behavior of opening dropped files, and stopPropagation() to keep the event from bubbling up to parent elements.

After importing the DragAndDrop component into App and rendering it, you will have a working drop zone without any logic beyond the event registration.

Managing Drop State With useReducer

The next step is planning how to track state during the drag-and-drop operation. Three distinct pieces of state need to be handled:

  • dropDepth, an integer used to track how deep we are in nested drop zones. This matters when a drop zone contains child elements that are also drop targets.
  • inDropZone, a boolean that tells us whether the cursor is currently inside the drop zone.
  • fileList, an array of the files that have been dropped.

React's useReducer hook is the right choice over useState for this situation because each state update depends on the previous state. The hook accepts a reducer of the form (state, action) => newState and returns the current state alongside a dispatch function for triggering updates.

Inside the App component, before the return statement, we define the reducer with case statements for each of the three state variables. Actions include a type and an optional payload, and the reducer returns the new state object based on the action type. The initial state is an object.

Once the reducer is in place, pass data and dispatch down to the DragAndDrop component as props. Inside DragAndDrop, these are destructured from props so you can use them in the event handlers and render logic.

Diagram showing nested drop zones A and B with boundaries labeled A-in, A-out, B-in, and B-out

The dropDepth mechanism is best illustrated with nested drop zones. In the diagram, zone A is the outer target, and zone B is nested inside it. When you drag toward the drop target, a dragenter event fires each time you cross a boundary. Entering zone B increments dropDepth again, but leaving zone B only decrements it, and the inDropZone flag remains true. Only after crossing the outer boundary (A-out) does dropDepth return to zero, and only then should inDropZone be set to false. This prevents flickering when the cursor simply moves over child elements within the larger drop zone.

Each event handler then follows this pattern:

  • The dragenter handler increments dropDepth and sets inDropZone to true. If the depth is greater than zero and we are re-entering from a child zone, we set the drop effect to copy.
  • The dragleave handler decrements dropDepth. If the depth drops to zero, we set inDropZone to false; otherwise, we return early because we're still inside a parent zone.
  • The dragover handler sets inDropZone to true and assigns copy to e.dataTransfer.dropEffect. On macOS, this shows the familiar green plus icon to indicate a copy operation.
  • In the drop handler, the files are accessed via e.dataTransfer.files. Because this is an array-like object, it gets converted to a standard JavaScript array with the spread operator. The handler filters out files already on the list, clears the dataTransfer object for the next operation, and resets dropDepth and inDropZone. Optional checks, such as rejecting files over a certain size, can be added at this point. With more than one file in the payload, only the first one is taken.

Rendering the Dropped Files

Feedback to the user comes from two places. First, conditionally swap the CSS class on the div in DragAndDrop based on data.inDropZone. A class like inside-drag-area, which lowers the opacity of the zone, is applied while dragging over the target.

Once files are dropped, render them by mapping through data.fileList in the App component and printing the filenames as list items below the drop zone. Testing the component will show the container's shift in opacity on hover, and files released on it being added to the on-page list.

There are opportunities to extend the component further. The drop handler could enforce constraints like file size or type, or the whole drop area could be made clickable so that it opens the file picker as well, with drag-and-drop remaining functional alongside the click interaction.