Building Complex Nested Drag and Drop User Interfaces With React DnD

Anne Zhou
Kustomer Engineering
10 min readJun 4, 2021

Here at Kustomer we aim for simplicity and strive to design user interfaces that are self-serving and easy to use for clients. Drag and drop features offer a straightforward experience that many modern web applications use. In this article, we will be showing you how to build a complex, nested grid-like layout with rows and columns using React DnD.

Drag and Drop Library Considerations

When building this concept out, we considered other drag and drop libraries such as react-beautiful-dnd. As noted in their documentation, react-beautiful-dnd is best suited for lists (vertical, horizontal, movements between lists, nested lists, and so on), and has limitations to provide more extensive drag and drop functionality. We ultimately went with React DnD for their powerful primitives to support handling user interfaces based on data changes.

React DnD keeps components decoupled with the data layer logic (that is why ready-made components like a <Sortable /> component are not available in this library). The library provides a set of utilities where dragging transfers data between different parts of the application, and the components change their appearance and application state in response to the drag and drop events.

Our goal is to build a feature where users can drag an item from a sidebar and drop it into the main layout. Sounds simple enough? What if the layout has nested components and some business or user experience logic layered on top? The layout consists of rows, and each row has a minimum of one column, and the columns can store an infinite amount of items. Users can also place draggable items (row, column, or component) into the trash bin, and move draggable items to its parent’s container (i.e., component into column or row level, column into row level).

Drag and Drop mockup
Drag and Drop mockup

Layout Data Structure

Before we get into the nitty-gritty of using React DnD, we want to abstract the view and think about the data structure of how we want to store this layout data. To represent and iterate through the layout, we have an array that is nested at most three-levels deep. This array contains objects where each object stores information about the type of object it is, the unique identifier, and a children array of objects that are the children of the parent object. We also have a components object that maps all the component data referenced in the layout. When we think about the sidebar items, this could be a static list — a flat array of static object data about each item.

In the simplest case, when we drag a sidebar item into the layout, we are adding a new object somewhere in the layout array depending on its position. Whether it’s inserting, removing, or reordering an object, we need a way to keep reference to the item’s current path and it’s drop zone path, and we can achieve this with the item’s index path in the layout array. For example, in the below gist, if we think about this in terms of raw data and want to add a new component in the second column of the first row of the layout, what would that item path be? It would be 0–1–1.

https://github.com/kustomer/react-dnd-example/blob/master/src/initial-data.js

In our drawing sample, every draggable item has a drop zone before and after it (via the light blue shading). Each drag source and drop target has their own respective paths. We use the indices from the current item path (drag source) and drop zone path (drop target) to handle adding, reordering, and removing items, and make updates to the layout accordingly based on the data stored in the layout array.

Core React DnD APIs

Let’s review two important hooks provided by React DnD: useDrag and useDrop.

useDrag is a hook that uses the current component as a drag source. You declaratively describe the item being dragged. In our example, we store the item type, id, and path data. The item type is required and must be set (the drop targets react to the type set on a drag source). The information described in this object will be made available to the drop target to consume and handle appropriately. The useDrag hook returns a few key items: a set of collected props (we destructure isDragging from the collecting function) and a drag ref that’s attached to the drag source. The isDragging prop gets injected into our component to be used for conditional styling.

https://github.com/kustomer/react-dnd-example/blob/master/src/Component.jsx

useDrop is a hook that uses the current component as a drop target. You can specify the types of data items the drop target will accept. drop(item, monitor) is called when a compatible item is dropped on a target (you can return a callback method here to set the state of the layout when an item is dragged to the trash bin). This method will not be called if canDrop is defined and returns false. canDrop(item, monitor) determines whether the drop target is able to accept the dragged item. Similar to the useDrag hook, the useDrop hook also returns a few key items: a set of collected props (we destructure isOver and canDrop from the collecting function) and a drop ref that’s attached to the drop target. These destructured props get injected into our component to be used for conditional styling.

https://github.com/kustomer/react-dnd-example/blob/master/src/TrashDropZone.jsx

In short, a drag source is the draggable component and it carries relevant information to the drop target to perform some action. A drop target is a container that accepts only certain types of drag sources and upon accepting a drag source, it will perform said action. Monitors, which are called in collecting functions, let you update the props of your components in response to the drag and drop state changes. In other words, they observe the state of each object — is the item being dragged? Is the item being dragged over a drop area?

Backends

React DnD uses the HTML5 Drag and Drop API, which comes with pluggable implementations for use in the browser, using mouse or touch events, etc. These pluggable implementations are known as the backends in React DnD. The library ships with the HTML5 backend, which is standard for most web applications.

Getting Started

Now that we have an understanding of the layout data structure and core APIs of React DnD, we can get started with building our drag and drop feature. To get started, we wrap our <Example /> app with a <DnDProvider /> injected with a HTML5 backend:

https://github.com/kustomer/react-dnd-example/blob/master/src/index.js

In our <Example /> component, we have the foundation of our app: the sidebar on the left hand side, and the page container on the right that will iterate through our layout constant. The Container function component starts out with an initial state of the layout array and the components object.

return (
<div className="body">
<div className="sideBar">
{Object.values(SIDEBAR_ITEMS).map((sideBarItem, index) => (
<SideBarItem key={sideBarItem.id} data={sideBarItem} />
))}
</div>
<div className="pageContainer">
<div className="page">
{/* iterate through layout array */}
</div>
</div>
</div>
);

Creating Our First Draggable Component

We want to make each static sidebar item a draggable item, so let’s build our first draggable component using the useDrag hook. As you can see from the code above, we’re iterating through each item in the sidebar and passing its data to the <SidebarItem /> component. The SideBarItem component is a very simple component that renders its own type. It takes data as props, uses the useDrag hook to set the item data, and has a collecting function to determine whether the item is dragging and returns that value as opacity to the component props. Once this component is created, you can start dragging the sidebar items and see the lighter opacity when the item is being dragged.

https://github.com/kustomer/react-dnd-example/blob/master/src/SideBarItem.jsx

Next, we want to iterate through our layout array and render each row with a <DropZone /> before and at the very end. As we iterate each row, we’re passing the currentPath data to the <DropZone /> component — since we’re starting with rows, we pass its own initial index, but as we iterate through columns and components, we continue to append the respective column and component index to that currentPath. renderRow returns each row data into its <Row /> component (which is its own draggable component).

<div className="pageContainer">
<div className="page">
{layout.map((row, index) => {
const currentPath = `${index}`;

return (
<React.Fragment key={row.id}>
<DropZone
data={{
path: currentPath,
childrenCount: layout.length
}}
onDrop={handleDrop}
path={currentPath}
/>
{renderRow(row, currentPath)}
</React.Fragment>
);
})}
<DropZone
data={{
path: `${layout.length}`,
childrenCount: layout.length
}}
onDrop={handleDrop}
isLast
/>
</div>

<TrashDropZone
data={{
layout
}}
onDrop={handleDropToTrashBin}
/>
</div>

Creating Our First Dropzone Component

We pass a handleDrop callback to the DropZone’s onDrop prop. The handleDrop callback contains the logic of moving a sidebar item into the page, reordering an item (row, column, or component) within the same parent, or moving an item into a different parent (which creates new wrapper components). For example, if you were to move a component into a completely different row, this would end up creating a new row and new column to house the drag source component. We also specify a canDrop method on the DropZone to determine whether a draggable item is droppable based on the item’s path. In our case, an item can’t be moved to its own location and we prevent a parent element like a row being dragged into a child element (column or component).

Data as the Source of Truth

For the purpose of this article, I will not be diving too deep into the helper methods within the handleDrop callback, but the helpers exist to recursively handle all the data updates in the layout array — we return the result of the updated layout back to the component to update its own local state, which in turns updates the UI. Depending on what your specific drag and drop use case is, these helpers can be modified to suit your needs. This goes back to the core concept of using React DnD. Data (not the views) serve as the source of truth. When you drag something across the screen, we don’t say a component or a DOM element is being dragged, we say an item of a certain type is being dragged to a new location.

https://github.com/kustomer/react-dnd-example/blob/master/src/DropZone.jsx

TrashDropZone is a similar component to DropZone. It’s outside of the main layout container, and is used as a way to clean up and remove items dragged over its own drop zone. We added some canDrop logic to prevent a user from removing a column if the row has only one column.

Hooking up Final Draggable Components

Once we have this base setup, creating the other draggable components follow a very similar pattern. The Row, Column, and Component draggable components all use a useDrag hook. Row and Column map over their children since rows have columns, and columns have components. The handleDrop callback is passed down from the parent component down to Row and Column since these components have dropzones. Likewise with the parent container in example.jsx, we pass the same type of data including the path data that the dropzone needs in Row and Column to determine whether an item is droppable. One important thing to note is that as we iterate through the children, we append the item index to the existing path:

const currentPath = `${path}-${index}`;

Indexing to a specific row would just be one number like 0. Indexing to a specific column would be two levels deep like 0–1 (first row, second column). Indexing to a specific component would be three levels deep like 0–1–0 (first row, second column, first component).

Some CSS Fun!

With these three draggable components implemented, we now have a working prototype of a deeply nested drag and drop interface! In the example below, we added some basic CSS to help visualize each component, and added visual indicators when a drop zone is active (an item is being dragged over and is able to drop over a drop zone). We also have conditional styling for dropzones in column spaces to take space in height because now you are horizontally dragging, in contrast to dropzones in rows and components to take space in width because you are vertically dragging. You can get fancy with your CSS and add smoother transitions and animations to make the drag and drop experience pop!

Nested drag and drop react-dnd prototype

With this prototype, we were able to build easy to use drag and drop features on our platform like this Klass View builder. No longer do clients need to know code to build out their card, they can now self-implement and use a visual drag and drop interface to design their layout.

See full code example:

https://github.com/annezhou920/react-dnd-example

Also, many thanks to Brandon Nydell for collaborating on this prototype!

--

--