Adding Drag and Drop to a React App

tl;dr: I implemented drag and drop using both dnd-kit and react-dnd. I ended up going with react-dnd because of its use of the HTML5 drag-and-drop API, which allowed for easier testing using cypress-drag-and-drop.

Introduction

I thought it would be interesting to add drag and drop to the application, it presents users with potentially more intuitive ways of grouping data.

Inside of Planner, I wanted for you to be able to assign a set of issues to a certain time period or "sprint".

So in this example, you might take the "Setup Github repository" task and drag it into the "Sprint 1" section. Seems simple enough.

I've looked at various libraries in the past but hadn't really done much with any of them. react-dnd was one I was familiar with, but I had recently seen some Twitter drama and someone suggested dnd-kit as a suggestion. So I gave dnd-kit a try and I really enjoyed the developer experience.

Using dnd-kit

dnd-kit provide a "useDroppable" hook that you can use in your target drop destination. They offer you a "setNodeRef" function that you just assign onto whatever HTML element you want to drop onto, and they have other states like "active" to let you know when stuff is being dragged so that you can style your drop zone.

  const { setNodeRef, active } = useDroppable({
    id: `sprint-droppable-${sprintId}`,
    data: {
      // You can provide any metadata about the zone you are 
      // dropping in
      sprintId, 
      sprintName,
    },
  });
  
  // DragTargetOverlay was just a custom component that renders 
  // an opaque layer over something that can be dropped on
  return (
    <DragTargetOverlay
      label="Move issue to backlog"
      isOpen={!!active}
      innerRef={setNodeRef}
    >
      {data.issues.length > 0 ? (
        <IssuesList issues={data.issues} />
      ) : (
        <div className="text-lg">No issues in the backlog</div>
      )}
    </DragTargetOverlay>
  );

Similarly, they offer a "useDraggable" hook which you can use to make elements draggable.

  const { attributes, listeners, setNodeRef, transform } = useDraggable({
    id: `draggable-issue-${issue.id}`,
    // Data that is made available once a drag event occurs
    data: issue,
  });
  
  const style = transform
    ? {
        transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
        zIndex: '100000',
      }
    : undefined;

  return (
    <div
      ref={setNodeRef}
      style={style}
      {...listeners}
      {...attributes}
    >
      Content that should be dragged
    </div>
    )

And finally, all of that code is nested under a "DndContext" which manages all of the events and callbacks.

  <DndContext
    onDragEnd={async (event) => {
     const draggableData = event.active.data.current;
     const droppableData = event.over?.data.current;
     
     const issueTag = parseIssueTagFromData(draggableData);
     const sprintId = parseIssueFromData(droppableData);
     
     await IssueService.updateIssue(
      issueTag,
      'sprintId',
      sprintId ?? null
    );
    }}
  />

This worked great. I loved it. It probably took me 30 minutes of looking at documentation and coding to get the functionality basically there.

Testing Hiccups with Cypress

However, I also perform testing using Cypress. So once I wrote the functionality, I started looking at options for testing drag and drop from within Cypress. I saw one main library being shared: cypress-drag-and-drop.

Generally, the test code would look something like this:

  cy.findByText(/Setup Github repository/i).drag(
    // Has to be a selector. 
    // It can't be another Cypress chainable element
    '#sprint-drag-overlay-0-body'
  );

Unfortunately, it didn't work out in my favor. While using cypress-drag-and-drop with dnd-kit, the test would initiate the "drag" aspect, but it would not actually drop the element.

Looking through the Cypress command, we can see that cypress-drag-and-drop is trying to use a DataTransfer object, an object that is used by the HTML5 drag-and-drop API, to mimic the behavior of actually dragging in the browser. Understandable but annoying for us.

dnd-kit explicitly calls out that it internally does not use the HTML5 drag-and-drop API. The reasonings it provide are sound, so I had to make a choice:

1. Figure out how to create testing batteries that could be used with dnd-kit

2. Rewrite my code to use a library that uses the HTML5 API so that I could use cypress-drag-and-drop.

Ultimately, I went with option 2. Generally, I try to encourage making testing as simple as possible so that tests actually do get written. If it's difficult to write the test, then it's unlikely the test will get written at all. And I generally have more control over my application code than I do when it comes to the internals of Cypress, even with their different commands and support files.

Using react-dnd

I rewrote my code using react-dnd, which internally uses the HTML5 API. In general, react-dnd is similar to dnd-kit. There are hooks called useDrag and useDrop which parallel the dnd-kit useDraggable and useDroppable hooks.

But the biggest difference was the DndProvider, the react-dnd equivalent of dnd-kit's DndContext. Two things stood out to me:

1. react-dnd will throw an error if you try to use useDrag or useDrop in a component that is not wrapped by DndProvider. In contrast, dnd-kit handles this scenario fine. I actually liked dnd-kit's behavior, because there were scenarios where the UI component that I was dragging in the sprints page was not actually draggable in other parts of the application. This meant with dnd-kit, I didn't have to add any abstractions or wrappers to handle those other scenarios, whereas with react-dnd, I had to rearchitect my components to manage the paths that would call the useDrop and useDrag hooks.

2. The event handler that occurs once a drag event ends is defined on the DndContext with dnd-kit with an "onDragEnd" prop. In contrast, in order to trigger an event handler for react-dnd, you add a "drop" callback in the configuration passed to the "useDrop" hook. This one is kind of subtle, but this means that action is handled in a top-down approach in dnd-kit and a bottom-up approach in react-dnd. With the bottom-up approach, your draggable components and your dropping components become generally aware of each other, whereas with the top-down approach, the parent knows and orchestrates the interactions. This top-down approach felt more natural to me and feels like it matches React's mental model better, so refactoring to the bottom-up took quite a bit more time.

Conclusion

I implemented drag and drop in my app using both dnd-kit and react-dnd. They both had their pros and cons and honestly, from a "get the feature done" aspect, both libraries suffice. The testing consideration is one that might trip future developers, so I thought it would be interesting to share.

Attributions

Blog Image Attribution

Image by pressfoto