Doofus Ideas Ltd

How To Use The HTML Drag-And-Drop API In React

The drag-and-drop API is one of the coolest features of HTML. It helps us implement drag-and-drop features in web browsers.

In the current context, we will be dragging files from outside the browser. On dropping the file(s), we put them on a list and display their names. With the files in hand, we could then perform some other operation on the file(s), e.g. upload them to a cloud server.

In this tutorial, we’ll be focusing on how to implement the action of dragging and dropping in a React application. If what you need is a plain JavaScript implementation, perhaps you’d first like to read “How To Make A Drag-And-Drop File Uploader With Vanilla JavaScript,” an excellent tutorial written by Joseph Zimmerman not too long ago.

The dragenterdragleavedragover, And drop Events

There are eight different drag-and-drop events. Each one fires at a different stage of the drag-and-drop operation. In this tutorial, we’ll focus on the four that are fired when an item is dropped into a drop zone: dragenterdragleavedragover and drop.

  1. The dragenter event fires when a dragged item enters a valid drop target.
  2. The dragleave event fires when a dragged item leaves a valid drop target.
  3. The dragover event fires when a dragged item is being dragged over a valid drop target. (It fires every few hundred milliseconds.)
  4. The drop event fires when an item drops on a valid drop target, i.e dragged over and released.

We can turn any HTML element into a valid drop target by defining the ondragover and ondrop event handler attributes.

Drag-And-Drop Events In React

To get started, clone the tutorial repo from this URL:

https://github.com/chidimo/react-dnd.git

Copy

Check out the 01-start branch. Make sure you have yarn installed as well. You can get it from yarnpkg.com.

But if you prefer, create a new React project and replace the content of App.js with the code below:

import React from 'react';
import './App.css';

function App() {
  return (
    <div className="App">
      <h1>React drag-and-drop component</h1>
    </div>
  );
}
export default App;

Copy

Also, replace the content of App.css with the below CSS style:

.App {
  margin: 2rem;
  text-align: center;
}
h1 {
  color: #07F;
}
.drag-drop-zone {
  padding: 2rem;
  text-align: center;
  background: #07F;
  border-radius: 0.5rem;
  box-shadow: 5px 5px 10px #C0C0C0;
}
.drag-drop-zone p {
  color: #FFF;
}
.drag-drop-zone.inside-drag-area {
  opacity: 0.7;
}
.dropped-files li {
  color: #07F;
  padding: 3px;
  text-align: left;
  font-weight: bold;
}

Copy

If you cloned the repo, issue the following commands (in order) to start the app:

yarn # install dependencies
yarn start # start the app

Copy

The next step is to create a drag-and-drop component. Create a file DragAndDrop.js inside the src/ folder. Enter the following function inside the file:

import React from 'react';

const DragAndDrop = props => {
  const handleDragEnter = e => {
    e.preventDefault();
    e.stopPropagation();
  };
  const handleDragLeave = e => {
    e.preventDefault();
    e.stopPropagation();
  };
  const handleDragOver = e => {
    e.preventDefault();
    e.stopPropagation();
  };
  const handleDrop = e => {
    e.preventDefault();
    e.stopPropagation();
  };
  return (
    <div className={'drag-drop-zone'}
      onDrop={e => handleDrop(e)}
      onDragOver={e => handleDragOver(e)}
      onDragEnter={e => handleDragEnter(e)}
      onDragLeave={e => handleDragLeave(e)}
    >
      <p>Drag files here to upload</p>
    </div>
  );
};
export default DragAndDrop;

Copy

In the return div, we have defined our focus HTML event handler attributes. You can see that the only difference from pure HTML is the camel-casing.

The div is now a valid drop target since we have defined the onDragOver and onDrop event handler attributes.

We also defined functions to handle those events. Each of these handler function receives the event object as its argument.

For each of the event handlers, we call preventDefault() to stop the browser from executing its default behavior. The default browser behavior is to open the dropped file. We also call stopPropagation() to make sure that the event is not propagated from child to parent elements.

Import the DragAndDrop component into the App component and render it below the heading.

<div className="App">
  <h1>React drag-and-drop component</h1>
  <DragAndDrop />
</div>

Copy

Now view the component in the browser and you should see something like the image below.

div to be converted into a drop zone (Large preview)

If you’re following with the repo, the corresponding branch is 02-start-dragndrop

Managing State With The useReducer Hook

Our next step will be to write the logic for each of our event handlers. Before we do that, we have to consider how we intend to keep track of dropped files. This is where we begin to think about state management.

We will be keeping track of the following states during the drag-and-drop operation:

  1. dropDepth
    This will be an integer. We’ll use it to keep track of how many levels deep we are in the drop zone. Later on, I will explain this with an illustration. (Credits to Egor Egorov for shining a light on this one for me!)
  2. inDropZone
    This will be a boolean. We will use this to keep track of whether we’re inside the drop zone or not.
  3. FileList
    This will be a list. We’ll use it to keep track of files that have been dropped into the drop zone.

To handle states, React provides the useState and useReducer hooks. We’ll opt for the useReducer hook given that we will be dealing with situations where a state depends on the previous state.

The useReducer hook accepts a reducer of type (state, action) => newState, and returns the current state paired with a dispatch method.

You can read more about useReducer in the React docs.

Inside the App component (before the return statement), add the following code:

...
const reducer = (state, action) => {
  switch (action.type) {
    case 'SET_DROP_DEPTH':
      return { ...state, dropDepth: action.dropDepth }
    case 'SET_IN_DROP_ZONE':
      return { ...state, inDropZone: action.inDropZone };
    case 'ADD_FILE_TO_LIST':
      return { ...state, fileList: state.fileList.concat(action.files) };
    default:
      return state;
  }
};
const [data, dispatch] = React.useReducer(
  reducer, { dropDepth: 0, inDropZone: false, fileList: [] }
)
...

Copy

The useReducer hook accepts two arguments: a reducer and an initial state. It returns the current state and a dispatch function with which to update the state. The state is updated by dispatching an action that contains a type and an optional payload. The update made to the component’s state is dependent on what is returned from the case statement as a result of the action type. (Note here that our initial state is an object.)

For each of the state variables, we defined a corresponding case statement to update it. The update is performed by invoking the dispatch function returned by useReducer.

Now pass data and dispatch as props to the DragAndDrop component you have in your App.js file:

<DragAndDrop data={data} dispatch={dispatch} />

Copy

At the top of the DragAndDrop component, we can access both values from props.

const { data, dispatch } = props;

Copy

If you’re following with the repo, the corresponding branch is 03-define-reducers.

Let’s finish up the logic of our event handlers. Note that the ellipsis represents the two lines:

e.preventDefault()
e.stopPropagation()


const handleDragEnter = e => {
  ...
  dispatch({ type: 'SET_DROP_DEPTH', dropDepth: data.dropDepth + 1 });
};

const handleDragLeave = e => {
  ...
  dispatch({ type: 'SET_DROP_DEPTH', dropDepth: data.dropDepth - 1 });
  if (data.dropDepth > 0) return
  dispatch({ type: 'SET_IN_DROP_ZONE', inDropZone: false })
};

Copy

In the illustration that follows, we have nested drop zones A and B. A is our zone of interest. This is where we want to listen for drag-and-drop events.

An illustration of the ondragenter and ondragleave events (Large preview)

When dragging into a drop zone, each time we hit a boundary, the ondragenter event is fired. This happens at boundaries A-in and B-in. Since we’re entering the zone, we increment dropDepth.

Likewise, when dragging out of a drop zone, each time we hit a boundary, the ondragleave event is fired. This happens at boundaries A-out and B-out. Since we’re leaving the zone, we decrement the value of dropDepth. Notice that we don’t set inDropZone to false at boundary B-out. That is why we have this line to check the dropDepth and return from the function dropDepth greater than 0.

if (data.dropDepth > 0) return

Copy

This is because even though the ondragleave event is fired, we’re still within zone A. It is only after we have hit A-out, and dropDepth is now 0 that we set inDropZone to false. At this point, we have left all drop zones.

const handleDragOver = e => {
  ...
  e.dataTransfer.dropEffect = 'copy';
  dispatch({ type: 'SET_IN_DROP_ZONE', inDropZone: true });
};

Copy

Each time this event fires, we set inDropZone to true. This tells us that we’re inside the drop zone. We also set the dropEffect on the dataTransfer object to copy. On a Mac, this has the effect of showing a green plus sign as you drag an item around in the drop zone.

const handleDrop = e => {
  ...
  let files = [...e.dataTransfer.files];
  
  if (files && files.length > 0) {
    const existingFiles = data.fileList.map(f => f.name)
    files = files.filter(f => !existingFiles.includes(f.name))
    
    dispatch({ type: 'ADD_FILE_TO_LIST', files });
    e.dataTransfer.clearData();
    dispatch({ type: 'SET_DROP_DEPTH', dropDepth: 0 });
    dispatch({ type: 'SET_IN_DROP_ZONE', inDropZone: false });
  }
};

Copy

We can access the dropped files with e.dataTransfer.files. The value is an array-like object so we use the array spread syntax to convert it to a JavaScript array.

We now need to check if there is at least one file before attempting to add it to our array of files. We also make sure to not include files that are already on our fileList. The dataTransfer object is cleared in preparation for the next drag-and-drop operation. We also reset the values of dropDepth and inDropZone.

Update the className of the div in the DragAndDrop component. This will conditionally change the className of the div depending on the value of data.inDropZone.

<div className={data.inDropZone ? 'drag-drop-zone inside-drag-area' : 'drag-drop-zone'}
      ...
    >
  <p>Drag files here to upload</p>
</div>

Copy

Render the list of files in App.js by mapping through data.fileList.

<div className="App">
  <h1>React drag-and-drop component</h1>
  <DragAndDrop data={data} dispatch={dispatch} />
  <ol className="dropped-files">
    {data.fileList.map(f => {
      return (
        <li key={f.name}>{f.name}</li>
      )
    })}
  </ol>
</div>

Copy

Now try dragging and dropping some files onto the drop zone. You’ll see that as we enter the drop zone, the background becomes less opaque because the inside-drag-area class is activated.

When you release the files inside the drop zone, you’ll see the file names listed under the drop zone:

Drop zone showing low opacity during dragover (Large preview)
A list of files dropped into the drop zone (Large preview)

The complete version of this tutorial is on the 04-finish-handlers branch.

Conclusion

We’ve seen how to handle file uploads in React using the HTML drag-and-drop API. We’ve also learned how to manage state with the useReducer hook. We could extend the file handleDrop function. For example, we could add another check to limit file sizes if we wanted. This can come before or after the check for existing files. We could also make the drop zone clickable without affecting the drag-and-drop functionality.

Exit mobile version