The Widlarz Group Blog

Draggable elements with React-dnd

May 13, 2021

Drag and Drop

React

React Query

Mirage JS

TypeScript

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

Final version

Content

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

App schema

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.

Basic drag and drop

Current step

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.

Add dropzones

Current step

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.

Monitor didDrop

Current step

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.

Delete item

Current step

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}
  },
})

Changing folder

Current step

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.

Changing order

Current step

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.