BlogReactJS

Building a reusable drag and drop component with React

Written by Codemzy on October 19th, 2023

Drag and drop is a pretty cool feature to add to your web applications. But it can be a little tricky. With some trial and error, here's how I built a reusable drag-and-drop component for React (and all the code!).

This blog post is in two parts. In this post, I'll show you the code for the React drag-and-drop component. In the next post, I'll show you how to use it by building a Trello-like drag-and-drop list with it!

Last year I built a drag and drop file upload component with React. And that was pretty cool. But I’ve found that there are so many use cases for drag and drop, other than file uploads.

Like rearranging a list of items, drag and drop images in a text editor, moving pictures in an album, or changing the order of questions in a quiz.

The problem with the drag-and-drop file upload component is it can only be used for files. And I don’t want to have to reinvent the wheel each time I need a drag-and-drop feature in my applications.

So I decided to build a reusable drag-and-drop component.

I've called it Drag.

And here’s how we will use it for a simple drag and drop:

import Drag from './Drag';

function Basic() {

  function handleDrop() {
    // handle the drop
  };
  
  return (
    <Drag handleDrop={handleDrop}>
    
      <Drag.DragItem dragId="item-1">One</Drag.DragItem>
      <Drag.DragItem dragId="item-2">Two</Drag.DragItem>
      <Drag.DragItem dragId="item-3">Three</Drag.DragItem>
    
      <Drag.DropZone dropId={1}>
        <Drag.DropGuide dropId={1} />
      </Drag.DropZone>
    
      <Drag.DropZone dropId={2}>
        <Drag.DropGuide dropId={2} />
      </Drag.DropZone>
    </Drag>
  );
};

I created Drag as a compound component, which means that Drag will handle the state, but several subcomponents use that state to create the overall experience.

Since all of the subcomponents (DragItem, DropZone etc) can only be used as children of the Drag component, I like accessing them through dot notation. They should never be used outside of the Drag component, and this pattern makes it clear that they are part of Drag.

We can build more complex things too, like a list, where each list item is also a drop zone:

import Drag from './Drag';

function List({ list = [] }) {

  function handleDrop() {
    // handle the drop
  };
  
  return (
    <Drag handleDrop={handleDrop}>
      <ul>
        { list.map((item, i) => {
          return (
            <li key={card.id}>
              <Drag.DropGuide dropId={i}/>
              <Drag.DropZone dropId={i}>
                <Drag.DragItem dragId={item.id}>{item.name}</Drag.DragItem>
              </Drag.DropZone>
            </li>
          );
        })}
        <Drag.DropGuide as="li" dropId={list.length}/>
      </ul>
    </Drag>
  );
};

Don't worry if that all looks a bit complex - it doesn't look especially inviting to me - and I built it! We'll go through it line-by-line in this post, so let's get started!

But first, here's what we can build with it:

[codepen]

Pretty nice! We will cover that more in the next blog post, but in this post, let's take a deep dive into the Drag component so I can show you how it's built.

You can either code along with me or download the finished code to get started.

The Drag component file structure

Here's the file structure for the Drag component and its subcomponents:

πŸ“ components/
└── πŸ“ Drag/
  β”œβ”€β”€ πŸ“„ Drag.js
  β”œβ”€β”€ πŸ“„ DragItem.js
  β”œβ”€β”€ πŸ“„ DropGuide.js
  β”œβ”€β”€ πŸ“„ DropZone.js
  β”œβ”€β”€ πŸ“„ DropZones.js
  └── πŸ“„ index.js

I'll start by showing you the index.js file.

export * from './Drag';
export { default } from './Drag';

You don't need this index.js file, but it does something pretty cool. Instead of importing the Drag component to use in our other components like this:

import Drag from './Drag/Drag';
``

We use a more simple import when the `index.js` is in place:

```js
import Drag from './Drag';

It might seem like a small saving, but you import components a lot in React, and having the index.js cuts down on duplication.

I got introduced to this index.js idea from Josh Comeau's Delightful React File Structure post - thanks Josh!

The Drag component

πŸ“ components/
└── πŸ“ Drag/
  β”œβ”€β”€ πŸ“„ Drag.js β¬…
  β”œβ”€β”€ πŸ“„ DragItem.js
  β”œβ”€β”€ πŸ“„ DropGuide.js
  β”œβ”€β”€ πŸ“„ DropZone.js
  β”œβ”€β”€ πŸ“„ DropZones.js
  └── πŸ“„ index.js

Ok, let's start with our main component, the parent that controls the whole operation - Drag.js!

Here's the basic component structure we will start with.

import React from 'react';

// context for the drag
const DragContext = React.createContext();

// drag context component
function Drag({ draggable = true, handleDrop, children }) {
  const [dragItem, setDragItem] = React.useState(null); // the item id being dragged
  const [dragType, setDragType] = React.useState(null); // if multiple types of drag item
  const [isDragging, setIsDragging] = React.useState(null); // drag is happening
  const [drop, setDrop] = React.useState(null); // the active dropzone

  const dragStart = function() {
    // to do
  };
  
  const drag = function() {
    // to do
  };

  const dragEnd = function() {
    // to do
  };

  const onDrop = function() {
    // to do
  };

  return (
    <DragContext.Provider value={{ draggable, dragItem, dragType, isDragging, dragStart, drag, dragEnd, drop, setDrop, onDrop }}>
      { typeof children === "function" ? children({ activeItem: dragItem, activeType: dragType, isDragging }) : children }
    </DragContext.Provider>
  );
};

We start by creating DragContext because that is how the Drag component will share data with its subcomponents (children).

The Drag component takes three props. draggable - which we will talk more about in the DragItem sub-component. handleDrop - which is the function it calls when an item is dropped. And children - which will get returned with our component.

And there are also four items of state:

  • dragItem - which will be an ID so we know which item is being dragged.
  • dragType - which will be optional, but useful if there are different types of things being dragged, for example in Trello you can drag lists and cards.
  • isDragging - which tells us if the item has started dragging yet
  • drop - which will tell us which drop zone is active at any time, so we can highlight it to show the user it is active, show a guide, and ultimately place the item in whichever drop zone is active when the item is dropped.

And we will pass all of the states and functions in the context value <DragContext.Provider value={{ draggable, dragItem, dragType, isDragging, dragStart, drag, dragEnd, drop, setDrop, onDrop }}> so we can use them in our subcomponents.

But our functions don't do anything yet, so let's code them up!

The dragStart function

const dragStart = function(e, dragId, dragType) {
  e.stopPropagation();
  e.dataTransfer.effectAllowed = 'move';
  setDragItem(dragId);
  dragType && setDragType(dragType);
};

The dragStart function will take the event (e), dragId and dragType.

We run e.stopPropagation() so that the event doesn't bubble up and trigger some other parent component. We're going to handle the drag right here.

And as it's a drag-and-drop component, we're going to allow the "move" effect. Since that's what we are doing - moving things!

The dragStart function sets the dragItem and dragType states. The dragId should be some unique ID so you know which item is being dragged. And the dragType is optional, but useful* if you have multiple different types of things being dragged and you don't want to get them mixed up.

The drag function

const drag = function(e) {
  e.stopPropagation();
  setIsDragging(true);
};

The drag function sets the isDragging state.

*You'll see this in action when we build the Trello-style drag and drop where both cards and lists can be dragged.

Originally, I didn't have a drag function - or the isDragging state.

And this works if you are leaving the element in the DOM - maybe just adding some opacity to fade it out. But when I was building my Trello-like drag and drop, I found a problem.

When you start a drag, you'll notice the browser creates a duplicate image of the item you are dragging (the drag image) and it follows your cursor around during the drag.

If you want to hide the item you are dragging, e.g. with the display: none; style, and you set the drag item on drag start, the style gets applied before the drag image is created.

This means the item also gets hidden in the drag image!

Not cool.

So by setting the isDragging state on the "drag" event, it happens after the "dragstart" event, meaning I can hide it, turn it red, or do whatever without breaking the drag image.

The dragEnd function

const dragEnd = function() {
  setDragItem(null);
  setDragType(null);
  setIsDragging(false);
  setDrop(null);
};

dragEnd resets our state when the drag event finishes.

This isn't the only drag-and-drop issue I've run into - here's some common drag-and-drop bugs and how to fix them.

The onDrop function

const onDrop = function(e) {
  e.preventDefault();
  handleDrop({ dragItem, dragType, drop });
  setDragItem(null);
  setDragType(null);
  setIsDragging(false);
  setDrop(null);
};

The onDrop function will take the event (e) and run e.preventDefault() so that the event so that the default browser behaviour doesn't run. We're going to handle the drop ourselves.

It then calls the handleDrop function with the dragItem, dragType and drop states. handleDrop will be passed to the Drag component by the parent, which is what makes Drag reusable - I'll show you how to wire this up in the next post.

onDrop will also reset the state, because the drag event has finished.

Render props

One final thing we will do is set some render props. Instead of returning {children} - we want to expose a few of items of state.

  • dragItem as activeItem
  • dragType as activeType
  • isDragging
{ typeof children === "function" ? children({ activeItem: dragItem, activeType: dragType, isDragging }) : children }

We set the activeItem and activeType render props, so that we can style items being dragged in the parent component. Like this:

<Drag>
  {({ activeItem }) => (
    <Drag.DragItem className={activeItem === thisId ? "opacity-50" : "show"} dragId={card.id}>My Item</Drag.DragItem>
  )}
</Drag>

If you are hiding the original element, make sure to use isDragging. This will hide the element after the drag starts so that the drag image is still created:

<Drag>
  {({ activeItem, isDragging }) => (
    <Drag.DragItem className={activeItem === thisId && isDragging ? "hidden" : "show"} dragId={card.id}>My Item</Drag.DragItem>
  )}
</Drag>

Here's the final code for the Drag.js file:

import React from 'react';

// sub-components
import DragItem from './DragItem';
import DropZone from './DropZone';
import DropZones from './DropZones';
import DropGuide from './DropGuide';

// context for the drag
export const DragContext = React.createContext();

// drag context component
function Drag({ draggable = true, handleDrop, children }) {
  const [dragItem, setDragItem] = React.useState(null); // the item id being dragged
  const [dragType, setDragType] = React.useState(null); // if multiple types of drag item
  const [isDragging, setIsDragging] = React.useState(null); // drag is happening
  const [drop, setDrop] = React.useState(null); // the active dropzone

  React.useEffect(() => {
    if (dragItem) {
      document.body.style.cursor = "grabbing"; // changes mouse to grabbing while dragging
    } else {
      document.body.style.cursor = "default"; // back to default when no dragItem
    }
  }, [dragItem]); // runs when dragItem state changes
  
  const dragStart = function(e, dragId, dragType) {
    e.stopPropagation();
    e.dataTransfer.effectAllowed = 'move';
    setDragItem(dragId);
    dragType && setDragType(dragType);
  };

  const drag = function(e) {
    e.stopPropagation();
    setIsDragging(true);
  };

  const dragEnd = function() {
    setDragItem(null);
    setDragType(null);
    setIsDragging(false);
    setDrop(null);
  };

  const onDrop = function(e) {
    e.preventDefault();
    handleDrop({ dragItem, dragType, drop });
    setDragItem(null);
    setDragType(null);
    setIsDragging(false);
    setDrop(null);
  };

  return (
    <DragContext.Provider value={{ draggable, dragItem, dragType, isDragging, dragStart, drag, dragEnd, drop, setDrop, onDrop }}>
      { typeof children === "function" ? children({ activeItem: dragItem, activeType: dragType, isDragging }) : children }
    </DragContext.Provider>
  );
};

// export Drag and assign sub-components
export default Object.assign(Drag, { DragItem, DropZone, DropZones, DropGuide });

Woop, woop! πŸ™Œ

I've also imported the sub-components (not built yet!) and assigned them to Drag so that I can access them through the dot notation as we discussed earlier.

And I added some code in a useEffect to change the cursor to the "grabbing" icon when a drag is happening.

That's the hardest part (I promise!).

Let's move on to building the sub-components.

The DragItem component

πŸ“ components/
└── πŸ“ Drag/
  β”œβ”€β”€ πŸ“„ Drag.js
  β”œβ”€β”€ πŸ“„ DragItem.js β¬…
  β”œβ”€β”€ πŸ“„ DropGuide.js
  β”œβ”€β”€ πŸ“„ DropZone.js
  β”œβ”€β”€ πŸ“„ DropZones.js
  └── πŸ“„ index.js

The first part of any good drag-and-drop is being able to drag things around.

And that's what our DragItem component will let us do!

import React from 'react';

// context
import DragContext from './Drag';

// a draggable item
function DragItem({ as, dragId, dragType, ...props }) {
  const { draggable, dragStart, drag, dragEnd } = React.useContext(DragContext);

  let Component = as || "div";
  return <Component onDragStart={(e) => dragStart(e, dragId, dragType)} onDrag={drag} draggable={draggable} onDragEnd={dragEnd} {...props} />;
};

export default DragItem;

Okay, so what's happening here?

First, we get some useful information from DragContext.

draggable tells us if the item can be dragged (we can set this to false if we want to disable drag and drop for any reason).

drag and dragEnd are called onDrag and onDragEnd for our item.

And the more interesting one, dragStart. When the onDragStart event happens for the drag item, we call the dragStart function and pass it the event, the dragId and dragType.

Now our Drag component knows which item is being dragged!

We also use the "as" prop, so that we can change the component our DragItem returns, from a "div" (default) to something else like "li" or whatever we might need.

The DropZone component

πŸ“ components/
└── πŸ“ Drag/
  β”œβ”€β”€ πŸ“„ Drag.js
  β”œβ”€β”€ πŸ“„ DragItem.js
  β”œβ”€β”€ πŸ“„ DropGuide.js
  β”œβ”€β”€ πŸ“„ DropZone.js β¬…
  β”œβ”€β”€ πŸ“„ DropZones.js
  └── πŸ“„ index.js

The DropZone component is, you guessed it, a place where we can drop any items we drag!

Here's how it looks:

import React from 'react';

// context
import DragContext from './Drag';

// listens for drags over drop zones
function DropZone({ as, dropId, dropType, style, children, ...props }) {
  const { dragItem, dragType, setDrop, drop, onDrop } = React.useContext(DragContext);
  
  function handleDragOver(e) {
    if (e.preventDefault) {
      e.preventDefault();
    }
    return false;
  };

  let Component = as || "div";
  return ( 
    <Component onDragEnter={(e) => dragItem && dropType === dragType && setDrop(dropId)} onDragOver={handleDragOver} onDrop={onDrop} style={{position: "relative", ...style}} {...props}>
      { children }
      { drop === dropId && <div style={{position: "absolute", inset: "0px"}}></div> }
    </Component>
  );
};

export default DropZone;

onDragEnter we check if something is being dragged and (if specified) we check that the thing being dragged is the same dragType as our dropType. If they don't match - this drop zone isn't for that type of drag item. If they do match, we set the drop ID to this drop zone with setDrop.

This tells the Drag component that this drop zone is active and allows us to add some styles to show the user they are in a drop zone and can drop the item if they wish.

We also have an "absolute" positioned element:

{ drop === dropId && <div style={{position: "absolute", inset: "0px"}}>

This is important because if there is any text or children in the drop zone, the "dragenter" and "dragleave" events will keep getting triggered as we drag across the child elements. This div covers them up during the drag to stop that from happening!

The DropZone is responsible for callingonDrop if an item is dropped so we can handle the drop event.

The handleDragOver function

We'll need to be able to specify drop targets by preventing the default drag over events, this is what the handleDragOver function does.

We call it onDragOver and it runs e.preventDefault();.

The DropZones component

πŸ“ components/
└── πŸ“ Drag/
  β”œβ”€β”€ πŸ“„ Drag.js
  β”œβ”€β”€ πŸ“„ DragItem.js
  β”œβ”€β”€ πŸ“„ DropGuide.js
  β”œβ”€β”€ πŸ“„ DropZone.js
  β”œβ”€β”€ πŸ“„ DropZones.js β¬…
  └── πŸ“„ index.js

I explain this component a bit more when I create the Trello-like drag-and-drop list. But in a nutshell, sometimes I need two drop zones over an element.

This usually is the case if I am rearranging items. Rather than dropping items in a drop zone, I'll be dropping them before or after it.

If I drag an item over the top half of the drop zone, I want to drop my item above the drop zone, and if I drag it over the bottom half of the drop zone, I want to drop it after the drop zone.

Here's the component:

import React from 'react';
import DropZone from './DropZone';

// context
import DragContext from './Drag';

// if we need multiple dropzones
function DropZones({ dropType, prevId, nextId, split = "y", remember, children, ...props }) {
  const { dragType, isDragging } = React.useContext(DragContext);
  return (
    <div style={{position: "relative"}} {...props}>
      { children }
      { dragType === dropType && isDragging &&
        <div style={{position: "absolute", inset: "0px", display: "flex", flexDirection: split === "x" ? "row" : "column" }}>
          <DropZone dropId={prevId} style={{ width: "100%", height: "100%" }} dropType={dropType} remember={remember} />
          <DropZone dropId={nextId} style={{ width: "100%", height: "100%" }} dropType={dropType} remember={remember} />
        </div>
      }
    </div>
  );
};

export default DropZones;

You'll only need this component if you need to split your drop zones in half - check out this blog post for an example of that use case.

The DropGuide component

πŸ“ components/
└── πŸ“ Drag/
  β”œβ”€β”€ πŸ“„ Drag.js
  β”œβ”€β”€ πŸ“„ DragItem.js
  β”œβ”€β”€ πŸ“„ DropGuide.js β¬…
  β”œβ”€β”€ πŸ“„ DropZone.js
  β”œβ”€β”€ πŸ“„ DropZones.js
  └── πŸ“„ index.js

I added this component because sometimes you're not dropping items inside drop zones, but rather, next to them. This can happen when re-arranging lists with drag and drop.

The DropGuide component can be used to show the user where the item will be placed after the drop.

import React from 'react';

// context
import DragContext from './Drag';

// indicates where the drop will go when dragging over a dropzone
function DropGuide({ as, dropId, ...props }) {
  const { drop } = React.useContext(DragContext);
  let Component = as || "div";
  return drop === dropId ? <Component {...props} /> : null;
};

export default DropGuide;

We can now show our users where the item they are dragging will be dropped. The guide with only show when the drop matches the dropId for the guide. And we can style it however we like since all the props get passed through.


You can find and download the Drag component code on GitHub.

To show you how to use this component in the real world, let's use Drag to build a Trello-like drag-and-drop list.

I hope this drag-and-drop component helps you. Let me know what you build with it!