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:
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:
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.
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.
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.
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.
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