Simplifying Drag and Drop (Lists and Nested Lists)

I always forget how to use any drag and drop library until I have to use it again. Currently, the one I've been working with is react-beautiful-dnd, the library created by Atlassian for products such as Jira. I'll admit, I'm not usually the biggest fan of Atlassian products, but it's a good library for working with drag and drop, particularly for usage with lists.

react-beautiful-dnd does tend to get a bit verbose, especially when working with nested lists, so I moved a lot of the details to reusable components and made some demos to share with you.

Goals and Demos

I made two demos for this article. In the first, I create Drag and Drop components that simplify usage with the react-beautiful-dnd library. In the second, I use those components again for a nested drag and drop, in which you can drag categories or drag items between categories.

These demos have almost no styling whatsoever - I'm more interested in showing the raw functionality with as little style as possible, so don't pay too much attention to how pretty it is(n't).

Simple Drag and Drop List

First, we'll make a simple drag and drop list.

You'll need a reorder function for getting the new order of whatever has been dragged and dropped:

helpers.js
export const reorder = (list, startIndex, endIndex) => {
  const result = Array.from(list)
  const [removed] = result.splice(startIndex, 1)
  result.splice(endIndex, 0, removed)

  return result
}

The DragDropContext, Draggable, and Droppable components work to create the list with draggable items, so I made a ListComponent that handles a complete draggable/droppable list:

ListComponent.js
import React, { useState } from 'react'
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd'
import { reorder } from './helpers.js'

export const ListComponent = () => {
  const [items, setItems] = useState([
    { id: 'abc', name: 'First' },
    { id: 'def', name: 'Second' },
  ])

  const handleDragEnd = (result) => {
    const { source, destination } = result

    if (!destination) {
      return
    }

    const reorderedItems = reorder(items, source.index, destination.index)

    setItems(reorderedItems)
  }

  return (
    <DragDropContext onDragEnd={handleDragEnd}>
      <Droppable droppableId="droppable-id">
        {(provided, snapshot) => {
          return (
            <div ref={provided.innerRef} {...provided.droppableProps}>
              {items.map((item, index) => {
                return (
                  <Draggable draggableId={item.id} index={index}>
                    {(provided, snapshot) => {
                      return (
                        <div ref={provided.innerRef} {...provided.draggableProps}>
                          <div {...provided.dragHandleProps}>Drag handle</div>
                          <div>{item.name}</div>
                        </div>
                      )
                    }}
                  </Draggable>
                )
              })}
              {provided.placeholder}
            </div>
          )
        }}
      </Droppable>
    </DragDropContext>
  )
}

As you can see, even in the simplest example it gets nested like 12 levels deep. Good thing we use 2-space indentation in JavaScript! We also have multiple provided and snapshot to deal with, and when it gets nested you now have four of them, and multiple placeholder, so it starts to get really confusing.

I made it slightly worse by not using implicit returns, but personally I really dislike using implicit returns because it makes it harder to debug (read: console.log()) things.

In any case, I like to break these out into their own components: Drag and Drop.

Drop

The Drop contains the Droppable, and passes the references and props along. I've also added a type which will be used with the nested drag and drop, but can be ignored for now.

Drop.js
import { Droppable } from 'react-beautiful-dnd'

export const Drop = ({ id, type, ...props }) => {
  return (
    <Droppable droppableId={id} type={type}>
      {(provided) => {
        return (
          <div ref={provided.innerRef} {...provided.droppableProps} {...props}>
            {props.children}
            {provided.placeholder}
          </div>
        )
      }}
    </Droppable>
  )
}

Drag

The Drag handles the drag handle (odd sentence), which is often represented by an icon of six dots, but for this simplified example, it's just a div with some text.

Drag.js
import { Draggable } from 'react-beautiful-dnd'

export const Drag = ({ id, index, ...props }) => {
  return (
    <Draggable draggableId={id} index={index}>
      {(provided, snapshot) => {
        return (
          <div ref={provided.innerRef} {...provided.draggableProps} {...props}>
            <div {...provided.dragHandleProps}>Drag handle</div>
            {props.children}
          </div>
        )
      }}
    </Draggable>
  )
}

Putting it together

I find it useful to make an index.js component that exports everything.

index.js
import { DragDropContext as DragAndDrop } from 'react-beautiful-dnd'
import { Drag } from './Drag'
import { Drop } from './Drop'

export { DragAndDrop, Drag, Drop }

Usage

Now, instead of all that nonsense from the beginning of the article, you can just use Drag and Drop:

import React, { useState } from 'react'
import { DragAndDrop, Drag, Drop } from './drag-and-drop'
import { reorder } from './helpers.js'

export const ListComponent = () => {
  const [items, setItems] = useState([
    /* ... */
  ])

  const handleDragEnd = (result) => {
    // ...
  }

  return (
    <DragAndDrop onDragEnd={handleDragEnd}>
      <Drop id="droppable-id">
        {items.map((item, index) => {
          return (
            <Drag key={item.id} id={item.id} index={index}>
              <div>{item.name}</div>
            </Drag>
          )
        })}
      </Drop>
    </DragAndDrop>
  )
}

You can see the whole thing working together on the demo.

Much easier to read, and you can make the draggable look however you want as long as you're using the same drag handle style. (I only added the most basic amount of styling to differentiate the elements.)

Nested Drag and Drop List with Categories

These components can also be used for nested drag and drop. The most important thing is to add a type for nested drag and drop to differentiate between dropping within the same outer category, or dropping between categories.

To start, instead of just having one items array, we're going to have a categories array, and each object within that array will contain items.

const categories = [
  {
    id: 'q101',
    name: 'Category 1',
    items: [
      { id: 'abc', name: 'First' },
      { id: 'def', name: 'Second' },
    ],
  },
  {
    id: 'wkqx',
    name: 'Category 2',
    items: [
      { id: 'ghi', name: 'Third' },
      { id: 'jkl', name: 'Fourth' },
    ],
  },
]

The handleDragEnd function gets a lot more complicated, because now we need to handle three things:

  • Dragging and dropping categories
  • Dragging and dropping items within the same category
  • Dragging and dropping items into a different category

To do this, we'll gather the droppableId of the source and destination, which will be the category ids. Then it's either a simple reorder, or the source needs to be added to the new destination. In the new handleDragEnd function below, you can see all three of these situations handled:

const handleDragEnd = (result) => {
  const { type, source, destination } = result

  if (!destination) return

  const sourceCategoryId = source.droppableId
  const destinationCategoryId = destination.droppableId

  // Reordering items
  if (type === 'droppable-item') {
    // If reordering within the same category
    if (sourceCategoryId === destinationCategoryId) {
      const updatedOrder = reorder(
        categories.find((category) => category.id === sourceCategoryId).items,
        source.index,
        destination.index
      )
      const updatedCategories = categories.map((category) =>
        category.id !== sourceCategoryId ? category : { ...category, items: updatedOrder }
      )

      setCategories(updatedCategories)
    } else {
      // Dragging to a different category
      const sourceOrder = categories.find((category) => category.id === sourceCategoryId).items
      const destinationOrder = categories.find(
        (category) => category.id === destinationCategoryId
      ).items

      const [removed] = sourceOrder.splice(source.index, 1)
      destinationOrder.splice(destination.index, 0, removed)

      destinationOrder[removed] = sourceOrder[removed]
      delete sourceOrder[removed]

      const updatedCategories = categories.map((category) =>
        category.id === sourceCategoryId
          ? { ...category, items: sourceOrder }
          : category.id === destinationCategoryId
          ? { ...category, items: destinationOrder }
          : category
      )

      setCategories(updatedCategories)
    }
  }

  // Reordering categories
  if (type === 'droppable-category') {
    const updatedCategories = reorder(categories, source.index, destination.index)

    setCategories(updatedCategories)
  }
}

Now you can see the category has a droppable-category type, and the item has a droppable-item type, which differentiates them. We now have two layers of <Drop> and <Drag> components.

NestedListComponent.js
import React, { useState } from 'react'
import { DragAndDrop, Drag, Drop } from './drag-and-drop'
import { reorder } from './helpers.js'

export const NestedListComponent = () => {
  const [categories, setCategories] = useState([
    /* ... */
  ])

  const handleDragEnd = (result) => {
    /* ... */
  }

  return (
    <DragAndDrop onDragEnd={handleDragEnd}>
      <Drop id="droppable" type="droppable-category">
        {categories.map((category, categoryIndex) => {
          return (
            <Drag key={category.id} id={category.id} index={categoryIndex}>
              <div>
                <h2>{category.name}</h2>

                <Drop key={category.id} id={category.id} type="droppable-item">
                  {category.items.map((item, index) => {
                    return (
                      <Drag key={item.id} id={item.id} index={index}>
                        <div>{item.name}</div>
                      </Drag>
                    )
                  })}
                </Drop>
              </div>
            </Drag>
          )
        })}
      </Drop>
    </DragAndDrop>
  )
}

I won't even show you what this looks like without the Drag and Drop components.

In the nested drag and drop demo, you can test out dragging between categories, dragging within a category, and dragging a category itself, including all the items it contains.

Conclusion

Drag and drop can get pretty unwieldy, especially when using the react-beautiful-dnd library for nested lists. By creating reusable components, you can make it much easier to use and understand.

I always try to see if I can tame my work when it seems like it's getting out of control, and this is just one example. Hope you enjoyed the article and demos!

Comments