Drag & Drop: Reorder Lists with react-dnd
crishanks
Posted on January 3, 2024
Drag & Drop with react-dnd
Stay Hydrated out there
Demo
.
Diving In
At the top level of our component we import 'react-dnd' and 'react-dnd-html5-backend' and hook up the DndProvider. If it's gonna be draggable, it's gotta be wrapped in the provider and passed a backend.
import "./styles.css";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { DropZone } from "./drop-zone";
const dndCharacterData = [
{ id: 1, classType: "Barbarian", race: "Dragonborn" },
{ id: 2, classType: "Barbarian", race: "Dwarf" },
{ id: 3, classType: "Fighter", race: "Elf" },
{ id: 4, classType: "Fighter", race: "Gnome" },
{ id: 5, classType: "Monk", race: "Half - elf" },
{ id: 6, classType: "Monk", race: "Half - orc" },
{ id: 7, classType: "Druid", race: "Halfling" },
{ id: 8, classType: "Druid", race: "Tiefling" }
];
export default function App() {
return (
<div
className="App"
style={{ display: "flex", flexDirection: "column", alignItems: "center" }}
>
<h1>react-dnd</h1>
{/* The DndProvider component provides React-DnD capabilities to our application. */}
{/* There are different backend types for different applications like 'touch' for mobile
but HTML5 is the primary supported backend. Use this unless you have a
really specific use case*/}
<DndProvider backend={HTML5Backend}>
<DropZone dndCharacterData={dndCharacterData} />
</DndProvider>
</div>
);
}
Now let's create a drop zone that surrounds our draggable items. Typically a drop zone configured component will have its own instance of useDrop, but when your dragabbles are always inside the drop zone, we don't need it (see the beginner example for contrast). So for this example, the DropZone component really serves as more of a container to extrapolate out some of the business logic associated with reordering our list.
import React, { useCallback, useState } from "react";
import { Draggable } from "./draggable";
import update from "immutability-helper";
export function DropZone({ dndCharacterData }) {
//grabbing api data and rendering it with `renderDndCharacterCards`
const [dndCharacters, setDndCharacters] = useState(dndCharacterData);
//a memoized function that uses js `immutability-helper` & `splice` to update the
//order of our rows
const moveRow = useCallback((dragIndex, hoverIndex) => {
setDndCharacters((prevCharacters) =>
update(prevCharacters, {
$splice: [
[dragIndex, 1],
[hoverIndex, 0, prevCharacters[dragIndex]]
]
})
);
}, []);
const renderDndCharacterCards = () =>
dndCharacters.map((dndCharacter, index) => (
<Draggable
index={index}
key={dndCharacter.id}
dndCharacter={dndCharacter}
moveRow={moveRow}
/>
));
return (
<div
style={{
border: "1px solid blue",
width: "80%",
display: "flex",
flexDirection: "column",
alignItems: "center",
padding: "20px",
gap: "10px"
}}
>
<h2>Drop Zone</h2>
{renderDndCharacterCards()}
</div>
);
}
Now let's get into the nitty gritty.
Our Draggable component is actually going to take care of almost all of the drag and drop functionality. We use the useDrop provided by react-dnd to establish a droppable area, and the useDrag hook to make something draggable. The two are connected by useDrop's accept property, and useDrag's type property. If those don't match, no draggy droppy.
import React, { useRef } from "react";
import { useDrag, useDrop } from "react-dnd";
//High Level 101 -- react-dnd uses drop areas and draggable items
export function Draggable({ dndCharacter, moveRow, index }) {
const { id, race, classType } = dndCharacter;
//react-dnd uses refs as its vehicle to make elements draggable
const ref = useRef(null);
//we use `useDrop` to designate a drop zone
//it returns `collectedProps` and a `drop` function
const [collectedProps, drop] = useDrop({
//collectedProps is an object containing collected properties from the collect function
//`accept` is very important. It determines what items are allowed to be dropped inside it
//this corresponds with the useDrag `type` value we'll see in a bit.
accept: "dnd-character",
//here's that collect function!
//Usually the info we want out of `collect()` comes from the `monitor` object
//react- dnd gives us. We can use `monitor` to know things about the state of dnd,
//like isDragging, clientOffset, etc.
//If we we want to expose this data outside of the hook and use in other places, we
//should return it as a part of the `collect(monitor)` function.
collect(monitor) {
return {
handlerId: monitor.getHandlerId()
// Example: maybe you want `isOver: monitor.isOver()` for dynamic styles
};
},
//`hover` gets called by react-dnd when an `accept`ed draggable item is hovering
//over the drop zone. There is a decent amount of vanilla js that is required to
//make the reorder ui work:
hover(item, monitor) {
if (!ref.current) {
return;
}
const dragIndex = item.index;
const hoverIndex = index;
// Don't replace items with themselves
if (dragIndex === hoverIndex) {
return;
}
// Determine rectangle on screen
const hoverBoundingRect = ref.current?.getBoundingClientRect();
// Get vertical middle
const hoverMiddleY =
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
// Determine mouse position
const clientOffset = monitor.getClientOffset();
// Get pixels to the top
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
// Only perform the move when the mouse has crossed half of the items height
// When dragging downwards, only move when the cursor is below 50%
// When dragging upwards, only move when the cursor is above 50%
// Dragging downwards
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
// Dragging upwards
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
// Time to actually perform the action
moveRow(dragIndex, hoverIndex);
item.index = hoverIndex;
}
});
//useDrag allows us to interact with the drag source
const [collectedDragProps, drag, preview] = useDrag({
//here's that `type` that corresponds with `accept`. These two have to align.
type: "dnd-character",
//`item` describes the item being dragged. It's called by react-dnd when drag begins.
//`item` gets passed into hover and we use that data there
item: () => {
return { id, index };
},
collect: (monitor) => ({
isDragging: monitor.isDragging()
})
});
//Here's an example of how we use `collectedProps`.
//I'm using bgColor instead of opacity to demonstrate the difference between the item
//being dragged and the preview, meaning the element in dragging state. isDragging affects
//the actual dragged element, not the preview.
//*Note: if we want to change the preview we would want to use a custom drag layer and render a preview component
const bgColor = collectedDragProps.isDragging ? "gray" : "";
//in the return statement, we assign the ref to be the value of the div
//Join the two refs together. This is a shorthand that allows us to create
//a drop zone around our draggables in one line.
drag(drop(ref));
return (
<div
//here's that ref
ref={ref}
style={{
border: "1px dotted",
width: "50%",
padding: "2px 12px",
backgroundColor: bgColor
}}
data-handler-id={collectedProps.handlerId}
>
<p>{`Class: ${classType}`}</p>
<p>{`Race: ${race}`}</p>
</div>
);
}
A couple of gotchas on the above code that are worth reiterating:
There is a difference between the preview and the draggable item we've given to the ref. The preview is what shows up when you start dragging. The draggable item is what actually moves when we reorder. If we want to interact with the preview we often do this with a 'Custom Drag Layer'. We aren't going over that in detail here, but we'll cover it in the advanced example.
Remember a few paragraphs ago when we said typically a drop zone's useDrag is separated from the draggable item component? Take a look at line 98, drag(drop(ref)); . Since our draggables are always going to be inside of a corresponding drop zone, react-dnd gives us a shorthand that says 'go ahead and combine the refs' so we just pass the combined ref to the containing draggable element we return in the Draggable.
There we have it. Our feature now has drag and drop.
Stay hydrated out there
Posted on January 3, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.