The Widlarz Group Blog
Draggable elements with React-dnd
May 13, 2021
TLDR
Repo
Demo app
Introduction
I recently had a chance to work with draggable HTML elements and I’d like to share some of my experience by creating a shopping list that will be controlled only by drag and drop. In order to do that, we will be using react-dnd. The first exposure to this library might be a little intimidating, but once you get how it works it’ll be a pleasant experience. To find out more about this library I highly recommend you to check out this documentation
In this article, we’re going to touch a little on the theory, but our goal is to create a functional example from the scratch, which is why it’s divided into two parts. The first portion concerns the frontend and the backend structure included in the starter. To create the starter, we used the create-react-app script and formed a simple frontend layer to save some time on building components. In addition to the frontend, we mocked a REST API backend with Mirage JS. We chose only the newest technologies such as react-query, logic based on custom hooks, and it’s all in the Typescript 🚀🚀🚀 The second part of this article concerns the drag and drop. We will go step by step through the process of connecting react-dnd to your application 😄
####Final version
Below is a gif of the final version that we will be building in the article. You can play with the table here: Demo
Content
- Introduction
- Project setup
- Backend structure
- Api hooks
- Frontend structure
- Drag and drop methods
- Summary
Project setup
A starter branch for this tutorial is available on GitHub
git clone -b starter git@github.com:TheWidlarzGroup/react-dnd-article.git
Then install packages
cd react-dnd-article && yarn
You can run this app with
yarn start
##Backend structure This chapter is mainly to let you know how the backend works. If you don’t want to go through the Backend and Frontend structures, you can go straight to the drag and drop part
As I mentioned before, the app uses Mirage JS as the mocked server; it allows creating every CRUD method with a specified endpoint, designing a database schema, and much more. You can even set up GraphQL API if you like.
First, let’s go through the endpoints and server methods. For the sake of simplicity, the whole server logic is contained in just one file.
The server file creates a database schema based on the objects we’ve passed to the server.db.loadData, and initializes route handlers. Route handlers take the route as the first parameter, and the function that handles your route as the second parameter. Function route handlers can take two arguments schema which allows for access to the mirage JS data layer and request which enables the definition of dynamic routes by using colon syntax (:id in your route, for example), or access body inside request in request.requestBody object.
####Endpoints:
List of the available backend endpoints
src/server/server.ts
export enum routes {
products = '/api/products',
folders = '/api/folders',
productsInFolder = '/api/folder/:folderId/products',
singleProductInFolder = '/api/folder/:folderId/product/:productId',
singleFolder = '/api/folder/:id',
singleProduct = '/api/product/:id',
updateFolders = '/api/folders/update',
updateProductsInFoldersOrder = '/api/folder/:folderId/products/updateOrder',
}
File with the whole server logic, mocked via mirage JS
src/server/server.ts
export const createMirageServer = () => {
const server = createServer({})
server.db.loadData({
productsInFolder,
folders,
productsMock,
})
server.get(routes.products, schema => ({
products: schema.db.productsMock,
}))
server.get(routes.folders, schema => schema.db.folders)
server.get(routes.productsInFolder, (schema, request) => {
const id = request.params.folderId
const products = schema.db.productsInFolder.where({folderId: id})
return products.sort(
(a: {order: number}, b: {order: number}) => a.order - b.order,
)
})
server.put(routes.productsInFolder, (schema, request) => {
const body: {id: string; folderId: string} = JSON.parse(request.requestBody)
return schema.db.productsInFolder.update(
{id: body.id},
{folderId: body.folderId},
)
})
server.put(routes.updateProductsInFoldersOrder, (schema, request) => {
const body: {products: ProductsInFolderType[]} = JSON.parse(
request.requestBody,
)
body.products.forEach((product, index) => {
schema.db.productsInFolder.update({id: product.id}, {order: index})
})
return schema.db.productsInFolder
})
server.delete(routes.singleProductInFolder, (schema, request) => {
schema.db.productsInFolder.remove({id: request.params.productId})
return {}
})
server.delete(routes.singleFolder, (schema, request) => {
const paramsId = request.params.id
schema.db.productsInFolder.remove({folderId: paramsId})
schema.db.folders.remove({id: paramsId})
return {}
})
server.delete(routes.singleProduct, (schema, request) => {
schema.db.productsMock.remove({id: request.params.id})
return {}
})
server.post(routes.singleFolder, (schema, request) => {
const body: {name: string; folderId: string} = JSON.parse(
request.requestBody,
)
return schema.db.productsInFolder.insert({
id: new Date().toISOString(),
...body,
})
})
server.post(routes.updateFolders, (schema, request) => {
const body: {folders: FoldersList[]} = JSON.parse(request.requestBody)
schema.db.folders.remove()
schema.db.folders.insert([...body.folders])
return schema.db.folders
})
}
The server starts with react app. To make this possible, you need to include createMirageServer on top of your app.
src/index.tsx
import {createMirageServer} from './server/server'
createMirageServer()
####Data fetching Data fetching will be done by using axios with react-query to handle requests and cache their result. In some cases, cached data will also be the source of data in this app. To save the query result in the cache and to interact with it, you need to wrap the app with QueryClientProvider and pass a queryClient.
index.tsx
import {QueryCache, QueryClient, QueryClientProvider} from 'react-query'
const queryCache = new QueryCache()
const queryClient = new QueryClient({queryCache})
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
After this step, you can use one of the react-query functionalities interacting with a cache. The data is stored in the cache under the key that was provided to useQuery. For easier maintenance of queries, queryKeys are stored in a separate file. This way, you can simply import the queryKey you need while avoiding typos.
const queryKeys = {
products: 'products',
folders: 'folders',
productsInFolder: (id?: string | null) => [id, 'folders'],
}
The useQuery requires at least a key for the query (queryKey in this case), and a promise that resolves the data or throws error. You can either pass a cache key, promise, and config object to useQuery in this exact order or use object syntax with at least queryKey and queryFn provided.
Key, function, config object
import axios from 'axios'
import {useQuery} from 'react-query'
import {routes} from '../../server/server'
import useQueryCache from './useQueryCache'
import {FoldersList} from '../../utils/types'
const getFolders = async () => {
const {data} = await axios.get(routes.folders)
return data
}
const useGetFolders = () => {
const {
queryKeys: {folders},
} = useQueryCache()
return useQuery<FoldersList[]>(folders, getFolders)
}
Object syntax
const getProductsInFolder = async ({queryKey}: QueryFunctionContext) => {
const {data} = await axios.get(`/api/folder/${queryKey[0]}/products`)
return data
}
const useGetProductsInFolder = (id?: string | null) => {
const {
queryKeys: {productsInFolder},
} = useQueryCache()
return useQuery<ProductsInFolderType[]>({
enabled: id !== undefined && id !== null, //check if query should trigger (optional)
queryKey: productsInFolder(id), //cache key (required)
queryFn: getProductsInFolder, //function to resolve (required)
})
}
The last thing you need to know is how to access your cached data.
Since react-query v3 you have to use useQueryClient
, with this hook you can use the getQueryData
method and pass one of the queryKeys to this function.
import {useQueryClient} from 'react-query'
const queryKeys = {
products: 'products',
folders: 'folders',
productsInFolder: (id?: string | null) => [id, 'folders'],
}
const queryClient = useQueryClient()
const cachedFolders = queryClient.getQueryData(
folders,
) as ProductsInFolderType[]
##Api hooks
For each call to the server, we will use a custom hook useApi
.
src/Hooks/Api/useApi.ts
const useApi = (id?: string | null) => {
const {mutate: putProductsInFolder} = usePutProductsInFolder()
const {mutate: deleteProductFromFolder} = useDeleteProductFromFolder()
const {mutate: deleteFolder} = useDeleteFolder()
const {mutate: deleteProductFromUpperList} = useDeleteProductFromUpperList()
const {mutate: postProductInFolder} = usePostProductInFolder()
const {mutate: updateFoldersOrder} = useUpdateOrder()
const {
mutate: updateProductsInFoldersOrder,
} = useUpdateProductsInFoldersOrder()
return {
products: useGetProducts(),
folders: useGetFolders(),
getProductsInFolder: useGetProductsInFolder(id),
putProductsInFolder,
deleteProductFromFolder,
deleteFolder,
deleteProductFromUpperList,
postProductInFolder,
updateFoldersOrder,
updateProductsInFoldersOrder,
}
}
The file collects all the CRUD methods used in this app. Each method is separated from others; this way you can avoid most of the unexpected behavior, and it makes maintenance and extending functionality much easier.
Here’s an example of using the useApi hook.
const {
folders: { data },
} = useApi()
####Data structure
As we use Typescript, we specify types for the data.
src/utils/types.ts
export interface ProductsType {
id: string
name: string
order: number
}
export interface FoldersList {
id: string
name: string
order: number
}
export interface ProductsInFolderType {
id: string
name: string
folderId: string
order: number
}
- Id - unique value for each product/folder/productInFolder
- name - name to display on frontend
- order - positive number, fetched data is sorted by this value before it’s displayed
- folderId - only for ProductsInFolder, this value is the id of the folder in which the product was dropped.
####Context
In this app, we created a context Main
, which provides a state with a currently active folder, and a method to set the active folder.
To use the context, there is a file for the provider, context, and a custom hook to consume it.
src/Contexts/MainContext.tsx
import {createContext} from 'react'
import {MainContextType} from '../Providers/MainProvider'
export const MainContext = createContext<MainContextType | null>(null)
Having such a context setup, you can access values passed to the provider by using the useMain hook if a file is inside this context scope.
const {activeFolder} = useMain()
##Frontend structure
App layout:
- Header - responsible for displaying a list of the fetched products;
- Main - contains a box with two sides;
- LeftSide - displays the list of the fetched folders;
- RightSide - displays the list of the products with folderId equal to the selected folder id;
- ProductListItem is used in the RightSide component as a folder and the LeftSide component as a product. The difference is the passed type and if it’s the product it also takes a folderId.
The above components should be your main focus. Later in the drag and drop part you will see how to connect all the dnd functionalities on them.
Components are styled with Tailwind CSS. Don’t worry if you’re not familiar with this library, it’s quite easy to understand, and there hasn’t been much styling done in it. In short, these are predefined classes.
##Drag and drop methods
####Introduction
While the previous section was mainly to let you know how the whole app works, in this part we will proceed with the actual code.
React-dnd uses items, types, and monitors to help you with work on your drag and drop app.
- Item with type is a plain JavaScript object with the type as a string defined inside it. It allows for describing a dragged element with whatever data you need and keeping draggable components separated from others.
- Monitors lets you update the props of your components in response to the drag and drop state changes. You can pass monitor functions to the component by using a collection function that can collect any methods which you might need.
Every component that you’d like to make draggable/droppable has to be marked as a drag or a drop source. What it does is tying the types, the items, the side effects, and the collecting functions together with your component. Drag source defines an item object with the declared type or optionally some extra methods that you might need for a given component. Drop target does not create an item; instead, it can accept many types of items and handle its drop or hover. Another thing about react-dnd is the backend that you provide. React DnD uses the HTML5 drag and drop API. The reason why it’s a good choice is that you don’t have to do any drawing when the cursor moves. That said, the downside of this backend is that it doesn’t support touch events. This library ships with HTML5Backend, but you’re not obligated to use this specific one. If you want, you can use any other backend such as Touch backend for mobile web applications.
####Provider The first thing you need to do is make sure your App or a part of it is wrapped with Dnd context provider and has initialized backend. For this project, we will be using HTML5Backend, which comes by default with this library.
Because it’s built on top of the HTML 5 drag and drop API this example won’t work on mobile devices.
src/index.tsx
<DndProvider backend={HTML5Backend}>
<App />
</DndProvider>
####Preview

####Basic drag and drop
Our first step will be to create a custom hook for each dnd method to make this code a little cleaner.
Let’s start with the first one, which will be responsible for making elements draggable.
This is the most basic drag method, which includes just the elements required by react-dnd. An item is an object that will be passed to other methods providing info about the dragged element. In this app, the item type can be a ‘folder’ or a ‘product’. Therefore, in the case of the useDragMethod hook, we have to pass the type parameter to be able to recognize whether we’re dragging a folder or a product.
src/Hooks/DragAndDrop/useDragMethod.tsx
import {useDrag} from 'react-dnd'
import {ItemTypes} from './types'
const useDragMethod = (type: ItemTypes) => {
const [drag] = useDrag({
item: {
type,
},
})
return {drag}
}
This is the most basic drop method. All that react-dnd requires for the drop method is pointing what types of item object are accepted for this drop. You can point just one or you can indicate many by passing types in an array. Of course, making it functional requires adding a little more code.
src/Hooks/DragAndDrop/useDropMethod.tsx
import {useDrop} from 'react-dnd'
import {ItemTypes} from './types'
const useDropMethod = () => {
const {folder, product} = ItemTypes
const [, drop] = useDrop({
accept: [folder, product],
})
return {drop}
}
Let’s add a console log to useDrop hook, and connect the drag and drop methods to our components to make sure it works.
const [, drop] = useDrop({
accept: [folder, product],
drop: item => {
console.log(item, 'dragged element')
},
})
src/Components/ProductListItem.tsx
const itemRef = useRef<HTMLDivElement>(null)
const {drag} = useDragMethod(type)
drag(itemRef)
return (
<div
className={`w-full border border-solid border-gray-300 py-4 px-6 flex justify-between `}
onClick={() => isFolder && setActiveFolder(id)}
ref={itemRef}>
src/Layout/LeftSide.tsx
const itemRef = useRef<HTMLDivElement>(null) //creating react ref
const {drop} = useDropMethod() //getting drop function from custom hook
drop(itemRef) //connecting drop method to referenced element
return (
<div className={`w-full`} ref={itemRef}>
This way we made every ProductListItem draggable and set LeftSide as a drop zone. When triggered by the drop, it will console log an item object defined in the useDragMethod hook.
####Connect all dropzones and extend item object
Now that we’re sure everything is working fine we can continue to expand the functionality. Let’s start with adding more information about the dragged elements such as id, name, and folderId if it’s a product because we need to know if it’s already in a folder.
src/Components/ProductListItem.tsx
const ProductListItem: FC<Props> = ({id, name, type, folderId}) => {
const {drag} = useDragMethod(id, name, type, folderId)
...
}
src/Hooks/DragAndDrop/useDragMethod.tsx
const useDragMethod = (
id: string,
name: string,
type: ItemTypes,
folderId?: string,
) => {
const [, drag] = useDrag({
item: {
type,
id,
name,
folderId,
},
})
return {drag}
}
This way, we got some useful information. Let’s add more drop zones and pass a proper location, so that the functionality for each drop zone can be different.
Update drop hook to receive location parameter:
src/Hooks/DragAndDrop/useDropMethod.tsx
const useDropMethod = (location: string) => {
const {folder, product} = ItemTypes
const [, drop] = useDrop({
accept: [folder, product],
drop: item => {
console.log(item, 'dragged element')
console.log(location, 'location')
},
})
return {drop}
}
Here’s an example of how to add it to the Main component.
src/Layout/Main.tsx
const Main = () => {
const itemRef = useRef<HTMLDivElement>(null)
const {drop} = useDropMethod(Dropzones.main)
drop(itemRef)
return (
<div
className={`h-full w-full flex justify-center items-center`}
ref={itemRef}>
<ListContainer />
</div>
)
}
In the same way, connect useDropMethod to the LeftSide and RightSide, and pass the correct Dropzone.
####Fix multiple drop trigger
Note that the Main component is over LeftSide and RightSide, so it also triggers even if you’ve dropped your element on one of these sides. To prevent such behavior, we can check if a drop event has already happened. In order to do that, we can use the 2nd parameter of the drop function monitor, which can give us plenty of useful information such as if an item has in fact dropped.
src/Hooks/DragAndDrop/useDropMethod.tsx
const useDropMethod = (location: string) => {
const {folder, product} = ItemTypes
const [, drop] = useDrop({
accept: [folder, product],
drop: (item, monitor) => {
if (monitor.didDrop()) return
console.log(item, 'dragged element')
console.log(location, 'location')
},
})
return {drop}
}
This way, drop triggers only on our desired place.
####Delete dragged element
Once we’re sure that everything is up and running, we can start connecting our API functionality to the drop method. Let’s begin with deleting folders and products when they are dropped in the main drop zone.
src/Hooks/DragAndDrop/useDropMethod.tsx
const useDropMethod = (location: string) => {
const {folder, product} = ItemTypes
const {
deleteProductFromFolder,
deleteFolder,
deleteProductFromUpperList,
} = useApi()
const [, drop] = useDrop({
accept: [folder, product],
drop: (item: DragItem, monitor) => {
if (monitor.didDrop()) return
const {id, type, folderId, name} = item
switch (type) {
case folder:
if (location !== Dropzones.main) return
deleteFolder({id})
break
case product:
if (location === Dropzones.main) {
if (folderId !== undefined) {
deleteProductFromFolder({folderId, id})
} else {
deleteProductFromUpperList({id})
}
break
}
break
}
},
})
return {drop}
}
Now, if a folder or a product is dropped in the main drop zone, it will make a call to the backend to delete itself.
####Change the product folder or add a new product
Next, let’s add an option to change the product folder or add a new product to the selected folder. To do that, we need to add another parameter for the useDropMethod hook, i.e. optional targetId, which will be an id of the currently selected folder if it’s on the RightSide or the id of the folder label that we’re dropping on the LeftSide. To make it happen, we need to connect the drop method to ProductListItem and connect the drag method to products in the upper list to make them functional as well.
Let’s start with updating the products in the header by making them draggable.
src/Layout/Header.tsx
import ProductUpperListContainer from '../Components/ProductUpperListContainer'
import useApi from '../Hooks/Api/useApi'
import {ItemTypes} from '../Hooks/DragAndDrop/types'
const Header = () => {
const {
products: {data},
} = useApi()
return (
<div className="w-full h-1/6 py-6 flex justify-items-center content-center bg-gray-200">
<div className="w-full flex flex-row flex-wrap gap-3 justify-center">
{data?.products.map(({id, name}) => (
<ProductUpperListContainer
key={id}
id={id}
name={name}
type={ItemTypes.product}
/>
))}
</div>
</div>
)
}
src/Components/ProductUpperListContainer.tsx
import {FC, useRef} from 'react'
import useDragMethod from '../Hooks/DragAndDrop/useDragMethod'
import {ItemTypes} from '../Hooks/DragAndDrop/types'
interface Props {
id: string
name: string
type: ItemTypes
}
const ProductUpperListContainer: FC<Props> = ({id, name, type}) => {
const itemRef = useRef<HTMLDivElement>(null)
const {drag} = useDragMethod(id, name, type)
drag(itemRef)
return (
<div
className="w-1/4 border border-gray-400 border-solid rounded-full flex justify-center items-center"
ref={itemRef}>
{name}
</div>
)
}
Once the items inside the upper list are draggable, you should add an optional parameter to useDropMethod which I’ve mentioned earlier.
src/Hooks/DragAndDrop/useDropMethod.tsx
const useDropMethod = (location: string, targetId?: string) => {
...
}
Now it’s time to update the hook call inside the components.
src/Layout/RightSide.tsx
const {activeFolder} = useMain()
const {
getProductsInFolder: {data},
} = useApi(activeFolder)
const itemRef = useRef<HTMLDivElement>(null)
const {drop} = useDropMethod(Dropzones.right, activeFolder ?? undefined)
drop(itemRef)
src/Components/ProductListItem.tsx
const {setActiveFolder} = useMain()
const isFolder = type === ItemTypes.folder
const location = isFolder ? Dropzones.left : Dropzones.right
const itemRef = useRef<HTMLDivElement>(null)
const {drag} = useDragMethod(id, name, type, folderId)
const {drop} = useDropMethod(location, isFolder ? id : undefined)
drag(drop(itemRef))
This way, if you drop a product on the RightSide component with an active folder, or a folder, it will pass a targetId, which will be equal to folder id. The last step is to connect API mutations with the new parameter.
src/Hooks/DragAndDrop/useDropMethod.tsx
const {
deleteProductFromFolder,
deleteFolder,
deleteProductFromUpperList,
putProductsInFolder,
postProductInFolder,
} = useApi()
...
switch (type) {
case folder:
... //previous code
case product:
... //previous code
if (targetId === undefined || folderId === targetId) return
if (folderId !== undefined) {
//update products that are already in other folder
putProductsInFolder({
id,
folderId,
targetFolderId: targetId,
})
} else {
//executes for products from the upper list
postProductInFolder({targetId, name})
}
break
}
The above code checks if the targetId exists and if the current product folderId is the same as the targetId. If any of these cases are true, the method will stop. In any other case, it checks if the product is already in the folder and makes a PUT call to update the folderId for the dragged item or POST call to add a dragged element from the upper list.
Lastly, we’re going to return a drop location. If we return a plain javascript object inside the drop method, it will be available inside the monitor.getDropResult() method. This is how the final version of the useDropMethod.tsx
hook looks like.
src/Hooks/DragAndDrop/useDropMethod.tsx
const [, drop] = useDrop({
accept: [folder, product],
drop: (item: DragItem, monitor) => {
if (monitor.didDrop()) return
const {id, type, folderId, name} = item
switch (type) {
case folder:
if (location !== Dropzones.main) return
deleteFolder({id})
break
case product:
if (location === Dropzones.main) {
if (folderId !== undefined) {
deleteProductFromFolder({folderId, id})
} else {
deleteProductFromUpperList({id})
}
break
}
if (targetId === undefined || folderId === targetId) return
if (folderId !== undefined) {
putProductsInFolder({
id,
folderId,
targetFolderId: targetId,
})
} else {
postProductInFolder({targetId, name})
}
break
}
return {location}
},
})
####Create a basic hover and update components
Hovering starts the same way as the drop method.
src/Hooks/DragAndDrop/useHoverMethod.tsx
const useHoverMethod = () => {
const {folder, product} = ItemTypes
const [, drop] = useDrop({
accept: [folder, product],
})
return {drop}
}
Let’s add a method to listen for hover event during dragging.
src/Hooks/DragAndDrop/useHoverMethod.tsx
const useHoverMethod = () => {
const {folder, product} = ItemTypes
const [, drop] = useDrop({
accept: [folder, product],
hover: (item: DragItem, monitor: DropTargetMonitor) => {},
})
return {drop}
}
To change the sequence of the elements, we need their current order. In this case, it will be an index from the array which they were mapped from. We need to update the components to receive an index and pass it to the drag hook.
src/Layout/Header.tsx
{data?.products.map(({id, name}, index) => (
<ProductUpperListContainer
key={id}
id={id}
name={name}
type={ItemTypes.product}
index={index}
/>
))}
src/Layout/RightSide.tsx
{data?.map(({id, name}, index) => (
<ProductListItem
key={id}
id={id}
name={name}
type={ItemTypes.product}
folderId={activeFolder !== null ? activeFolder : undefined}
index={index}
/>
))}
src/Layout/LeftSide.tsx
{data?.map((folder, index) => (
<ProductListItem
key={folder.id}
id={folder.id}
name={folder.name}
type={ItemTypes.folder}
index={index}
/>
))}
src/Components/ProductListItem.tsx
interface Props {
id: string
name: string
type: ItemTypes
index: number
folderId?: string
}
const ProductListItem: FC<Props> = ({id, name, type, folderId, index}) => {
const {setActiveFolder} = useMain()
const isFolder = type === ItemTypes.folder
const location = isFolder ? Dropzones.left : Dropzones.right
const itemRef = useRef<HTMLDivElement>(null)
const {drag} = useDragMethod(id, name, type, index, folderId)
const {drop} = useDropMethod(location, isFolder ? id : undefined)
const {drop: hoverDrop} = useHoverMethod()
drag(drop(hoverDrop(itemRef)))
return (...)
}
src/Components/ProductUpperListContainer.tsx
interface Props {
id: string
name: string
type: ItemTypes
index: number
}
const ProductUpperListContainer: FC<Props> = ({id, name, type, index}) => {
const itemRef = useRef<HTMLDivElement>(null)
const {drag} = useDragMethod(id, name, type, index)
drag(itemRef)
return (...)
}
src/Hooks/DragAndDrop/useDragMethod.tsx
const useDragMethod = (
id: string,
name: string,
type: ItemTypes,
index: number,
folderId?: string,
) => {
const [, drag] = useDrag({
item: {
type,
id,
name,
index,
folderId,
},
})
return {drag}
}
####Check if hovering action should happen
In the next step, we will create a few conditions to check if the action after hovering should trigger. This is necessary because it could activate too many times, sooner or later making the app unusable.
The exported function that will gather all the conditions is canMove.
src/Hooks/DragAndDrop/utils.ts
const {left, right} = Dropzones
const {folder, product} = ItemTypes
const basicCheck = (
location: string,
index: number,
type: ItemTypes,
hoverIndex: number,
folderId?: string | undefined,
) => {
if (index === undefined) return false
if (type === folder && location === right) return false
if (type === product && location === left) return false
if (type === product && folderId === undefined) return false
return index !== hoverIndex
}
const calculateMiddle = (
hoverBoundingRect: DOMRect,
monitor: DropTargetMonitor,
) => {
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2
const clientOffset = monitor.getClientOffset()
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top
return {hoverClientY, hoverMiddleY}
}
const checkUpAndDown = (
index: number,
hoverIndex: number,
hoverMiddleY: number,
hoverClientY: number,
) => {
// Dragging downwards
if (index < hoverIndex && hoverClientY < hoverMiddleY) return false
// Dragging upwards
return !(index > hoverIndex && hoverClientY > hoverMiddleY)
}
export const canMove = (
ref: RefObject<HTMLDivElement>,
item: DragItem,
location: string,
hoverIndex: number,
monitor: DropTargetMonitor,
) => {
const {index, type, folderId} = item
if (!ref.current) return false
const hoverBoundingRect = ref.current.getBoundingClientRect()
const {hoverMiddleY, hoverClientY} = calculateMiddle(
hoverBoundingRect,
monitor,
)
return (
basicCheck(location, index, type, hoverIndex, folderId) &&
checkUpAndDown(index, hoverIndex, hoverMiddleY, hoverClientY)
)
}
These conditions check if: the ref is connected to the HTML element, an item is dragged in the correct zone (basicCheck), we’ve dragged over half of the other element, and lastly if dragging takes place upward or downward.
####Finishing touches on useHover hook
After implementing all the necessary conditions, we can go on to create more functionality.
src/Hooks/DragAndDrop/useHoverMethod.tsx
const useHoverMethod = (
ref: RefObject<HTMLDivElement>,
hoverIndex: number,
location: string,
) => {
const {folder, product} = ItemTypes
const {left} = Dropzones
const {
queryClient,
queryKeys: {folders, productsInFolder},
} = useQueryCache()
const {activeFolder} = useMain()
const [, drop] = useDrop({
accept: [folder, product],
hover: (item: DragItem, monitor: DropTargetMonitor) => {
if (canMove(ref, item, location, hoverIndex, monitor)) {
const activeQueryKey =
location === left
? folders
: productsInFolder(activeFolder ?? undefined)
const data = queryClient.getQueryData(activeQueryKey) as []
const dragged = data[item.index]
data.splice(item.index, 1)
data.splice(hoverIndex, 0, dragged)
item.index = hoverIndex
queryClient.setQueryData(activeQueryKey, data)
}
},
})
return {drop}
}
src/Components/ProductListItem.tsx
const {drop: hoverDrop} = useHoverMethod(itemRef, index, location)
This is the final version of the hover method. Action that triggers if every condition above is true checks the queryKey based on location, then it gets data from the cache saved under the queryKey and gets all the information about the dragged item based on its index. Then, it swaps the position of the dragged item with the item that was dragged over and their indexes, and at the end sets new order in the cache to make changes visible for the user.
####Update order with backend call
Pretty much the last thing we need to do is connect this action to our backend. This way, the order in our app will remain persistent. For this purpose, we will use another method of useDrag hook - end. It will trigger right after dragging is over.
src/Hooks/DragAndDrop/useDragMethod.tsx
const {
queryClient,
queryKeys: {folders, productsInFolder},
} = useQueryCache()
const {updateFoldersOrder, updateProductsInFoldersOrder} = useApi()
const [, drag] = useDrag({
//... previous code
end: (_item, monitor) => {
const dropResult: {location: string} = monitor.getDropResult()
if (dropResult.location === Dropzones.main) return //check if item was removed
const {folder, product} = ItemTypes
switch (type) {
case folder:
const folderList = queryClient.getQueryData(folders) as FoldersList
updateFoldersOrder({arr: folderList})
break
case product:
if (folderId === undefined) return
const queryKey = productsInFolder(folderId)
const arr = queryClient.getQueryData(
queryKey,
) as ProductsInFolderType[]
updateProductsInFoldersOrder({folderId, arr})
break
}
},
})
This method checks if the item type is a folder or a product, and triggers a different call for each one. If it’s the folder, we simply need to get a previously updated list from the cache and send it to the backend to save the order. Basically the same goes for the product, the only difference being that it checks if an item contains folderId and sends one more parameter in a payload to our backend call.
Finishing this step makes the app fully functional.
##Summary
We have reached the end of this article. If you feel like it, you can keep playing with this repository, e.g. by changing the preview of the dragged element or adding some styles. React-dnd is a powerful library that offers much more than what was shown in this guide.
I hope that you learned something about drag and drop today 😄
If you want to play with this project, you can clone this repository from GitHub.

Written by Chris Cisek.