Whether you are a fervent phone user, or more a PC-and-mouse kind of person, chances are you've already come across a situation where you had to drag and drop elements on the screen. It's so inherent to modern browsing that you probably don't even think much of it anymore, you just click on that item you desire and drag it all the way to your cart to buy it, simple right? As simple as it should be, and React DnD library will make handling that interaction awfully painless, which is precisely what we are going to look at in this article. We will learn how to build this simple 'Make your own burger' widget, where you can drag an drop ingredients to cook up the perfect burger. You can pile up ingredients on the left stack, and re-order them as you please once they're set, and choose a drink to drop on the right-side drop zone. You can not drop your drink within the burger though!
Drop zone
Top bun
Bottom bun
Patty
Lettuce
Cheese
Sauce
Iced tea
Soda
Drop zone
Let's dive into each component one by one, from the bottom all the way up. As always, all code snippets have been cleared of the CSS markups that's only polluting and adds nothing to the comprehension.
The handles
Starting with the handles, which are the "the things being dragged". React DnD encourages us not to think about it as a "DOM node being dragged", but as an "item of certain type being dragged", and we will make a point to follow that advice.For this example, we will define 2 types of items, 'ingredients' and 'drinks' that both can be dragged and dropped in our widget.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { useDrag } from 'react-dnd';
const BurgerIngredient = ({ name, type = 'ingredient', disabled }) => {
const [{ isDragging }, drag] = useDrag(
() => ({
// "type" is required. It is used by the "accept" specification of drop targets.
type: type,
item: { name },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}),
[name]);
/* The drag ref marks this node as being the "pick-up" node */
return <div className={isDragging ? "some css" : "some other css"} ref={disabled ? null : drag}>{name}</div>
};
This BurgerIngredient component can render both 'ingredients' and 'drinks', here are some explanations:
It is receiving 3 props, the name which we will display, the type (either ingredient, or drink), and whether the handle is disabled or not. In our case, disabled means the ingredient has already been added to the burger, and can no longer be dragged from the ingredient list;
Block line 4 to 13, is how we connect our component to use the DnD hook 'useDrag'. We are passing to the hook the 'type' of what's being dragged, and the structure of the 'item' being dragged, here it is simply an object that holds the name of the ingredient. The 'collect' section allows us to tap into the internal state variables of the drag and drop interaction, here to get whether the current item being dragged (isDragging) or not;
Line 16 is the simple render of our component, where we pass the 'drag' reference we've obtained from the hook. Notice that we do not pass that reference if the handle should be disabled. Here can also use the 'isDragging' boolean to change the appearance of the item being dragged, usually by setting its opacity to 50%. That's what the burger widget does, but the CSS design is up to you.
Now once you've dropped an ingredient, it should show up as an image, and also display an 'x' to be able to delete the element. The code of the 'DroppedItem' component is very similar to the above, so we will not comment on it further:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { useDrag } from 'react-dnd';
const DroppedItem = ({ name, type = 'ingredient', onDelete }) => {
const [{ isDragging }, drag] = useDrag(
() => ({
// "type" is required. It is used by the "accept" specification of drop targets.
type: type,
item: { name },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}),
[name]);
return <div>
<div onClick={() => onDelete(name)}>
x
</div>
{/* The drag ref marks this node as being the "pick-up" node */}
<div ref={drag} ><img src={"/Code/Dnd/" + name + ".png"}/></div>
</div>;
};
Ingredients & drinks toolbox
In this section we will simply show how we are buidling up the 'toolbox' that is listing all ingredients and drinks.
Nothing groundbreaking here, we've got two constants that we are using to generate a list of 'BurgerIngredient'. It's important to note that we are setting the 'type' of the 'iced tea' and 'soda' to be 'drink', to make sure that the user won't be able to drop anything but drinks in the matching drop zones.
The buckets
Next up are drop zones, which are the buckets that will be receiving our dragged items. See below:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useDrop } from 'react-dnd';
const Dropzone = ({ accept, onDrop }) => {
const [{ canDrop, isOver }, drop] = useDrop(
() => ({
// The type (or types) to accept - strings or symbols
accept: accept,
drop: onDrop,
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}), [onDrop]);
return (
<div
ref={drop}
className={[canDrop ? 'css when an item is being dragged' : '',
isOver ? 'css when an item is over the div' : 'normal css')].join(" ")}
>
{canDrop ? 'Here!' : 'Drop zone'}
</div>
);
};
The Dropzone component has the following behaviour:
It is receiving 2 props, 'accept' is the type of item that will be allowed to land in the drop zone. In our case it is either 'ingredient' or 'drink', so that when you try to drop a drink above an 'ingredient only' dropzone, it will not work. 'onDrop' is a callback function which will perform the logic upon dropping an item into the bucket;
Block line 4 to 13, is how we connect our component to use the DnD hook. This time we are leveraging 'useDrop', but it is looking similar to the 'useDrag' used previously. We are passing to the hook an 'accept' parameter, which will restrict what kind of item type we'll be able to be dropped into it. 'onDrop' is our callback, it will be called with the value we defined as our 'item' and we implemented the 'useDrag' hook. In the 'collect' section, it is what internal state of the drag action we are interested in using. We'll use 'isOver' and 'canDrop' to modify the appearance of the drop zone when an item is being dragged, or is positioned over the zone;
Line 17 is where we attach the 'drop' reference to the div we want to use as bucket;
Lines 18 and 19, we are adding some CSS classes based upon the state of the dragging interaction;
Line 21, we are updating the text once an item starts being dragged around.
We've now got all our components but one, but it's pretty important piece! We need to wrap our draggable and droppable components with a context provider, also imported from React Dnd. That provider will detect the necessary events.
Backend
In order to support both desktop and mobile drag & drop interactions, we will have to import 2 separate backends. For desktop and the click and drag events, it is called 'HTML5' and can be installed within the npm package 'react-dnd-html5-backend'. For mobile and the touch events, it is called 'Touch' and can be installed within the npm package 'react-dnd-touch-backend'. Let's look at the code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ... other imports
import { HTML5Backend } from 'react-dnd-html5-backend';
import { TouchBackend } from 'react-dnd-touch-backend'
import { DndProvider } from 'react-dnd';
export default function App() {
let defaultDndBackend = HTML5Backend;
// On mobile, we need to pass another backend to support 'touch' events
if (window.screen.height < 1080) {
defaultDndBackend = TouchBackend;
}
return <div>
<DndProvider backend={defaultDndBackend}>
{/* ... your app here ... */}
</DndProvider>
</div>;
We are importing the 2 backends and the context provider 'DndProvider', and setting the default backend to be 'HTML5' for desktop. Then we check if the screen indicates the user is viewing the app on mobile, if so, we switch configuration to support touch events instead. Once that's done, we set up the context like any context in React, the rule is that it must be place above every component that needs it. So the top of your app is probably a good place to start. At this point, we've got items to drag, and buckets into which to drop them, so let's put everything together.
Putting everything together
Let's call 'BurgerBuilderZone' the component whose job is to stack our burger ingredients on top of one another. It should wrap any dropped ingredient with 2 drop zones, one above, one below.
Line 2, if no ingredients have been dropped so far, we are simply rendering one dropzone, which will accept an ingredient;
Otherwise, we map the list of dropped ingredients to yield one 'Dropzone' and 'DroppedComponent' for each (two dropzones for the first element only, to be consistent);
Notice how each drop zone is given an index, which React requires when mapping components, but also for another reason: when we have several drop zones, we need to know which one has received a dropped ingredient.
Phew, almost there! There is one final component that will tie all the others together, soberly called 'BurgerBuilder':
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
const ingredients = ["topBun", "bottomBun", "patty", "lettuce", "cheese", "sauce"];
const drinks = ["iced_tea", "soda"];
const isDrink = (item) => drinks.includes(item)
export const BurgerBuilder = () => {
const [droppedIngredients, setDroppedIngredients] = useState([]);
const [droppedDrink, setDroppedDrink] = useState('');
const handleDrop = useCallback((item, index) => {
const { name } = item;
if (isDrink(name)) {
setDroppedDrink(name);
} else {
// Shallow copy of the ingredients, no need to deep copy as we have an array of simple strings
let cloned = [...droppedIngredients];
// If the dropped item is 'relocating' (i.e. not dropped from the toolbox), we need to move it instead of creating a new one
if (droppedIngredients.includes(name)) {
const existingIndex = droppedIngredients.indexOf(name);
cloned[existingIndex] = "moved";
}
cloned.splice(index, 0, name);
cloned = cloned.filter(el => el !== "moved");
setDroppedIngredients(cloned);
}
}, [droppedDrink, droppedIngredients, setDroppedDrink, setDroppedIngredients]);
const onDeleteIngredient = (name) => {
const updatedItems = droppedIngredients.filter(el => name !== el);
setDroppedIngredients(updatedItems);
};
const onDeleteDrink = () => {
setDroppedDrink('');
};
const clearAll = () => {
setDroppedDrink('');
setDroppedIngredients([]);
}
return (
<>
<button onClick={clearAll}>Clear</button>
<div>
<BurgerBuilderZone
droppedIngredients={droppedIngredients}
handleDrop={handleDrop}
onDeleteIngredient={onDeleteIngredient}
/>
<BurgerToolbox droppedIngredients={droppedIngredients} droppedDrink={droppedDrink}/>
{droppedDrink === '' ?
<Dropzone
key={0}
accept='drink'
onDrop={(item: { name: string }) => handleDrop(item, 0)}
/> :
<DroppedItem name={droppedDrink} onDelete={onDeleteDrink} />
}
</div>
</>
);
};
There is a little more code in this last component, let's see how it works:
Lines 1 and 2, are the 2 constants we defined earlier, that were simply moved at the top for clarity;
Line 4, a simple method to check if an item is a drink;
Line 7 and 8, our component will maintain the list of ingredients and the drink as state variable, so we initialise them empty;
Block line 10, is the function we are going to pass down as callback when an item is dropped somewhere. We are expecting to get an item, which is an object with a 'name' property, and possibly an index;
Line 12, if the dropped item is a drink, we simply update the state value;
Block line 15, when an ingredient has been dropped, we clone the current state and perform some logic to update it. We are checking if we dropped a 'new' ingredient from the toolbox, or if it is just an ingredient moved up or down the stack. If the latter, we do an additional bit of logic to mark the previous index for deletion, perform the swap, and clean up the result array;
Block lines 28, 33 and 37, are self explanatory, they are just function to clean up the state when the user is going to click on a 'x' mark to remove an item;
Finally, the render puts everything together, first the 'BurgerBuilderZone', which needs to render dropped ingredients, handle new drops, and handle deletion of ingredients. Then the 'BurgerToolbox' which will display the list of basic ingredients and drinks the user can choose from. Lastly, if we don't currently have a drink, we are rendering a drop zone that can accept one, if we do have a drink though, we render a 'DroppedItem', which we pass the props necessary to clear the state related to drink variable.
That's about it! The goal of this article was to give a relatively simple example using the React Drag and Drop library, to highlight the mindset one needs to have when thinking about the interactions going on under the hood (an 'item' is being dragged from a 'handle' to a 'bucket'), and a couple of internal state variable made available through the hooks, like isDragging, canDrop, and isOver. Next time you need to implement drag and drop in your application, I strongly suggest looking into React DnD :)