DEV Community

Cover image for React Drag and Drop Made Easy: A Step-by-Step Guide
Francisco Mendes
Francisco Mendes

Posted on • Updated on

React Drag and Drop Made Easy: A Step-by-Step Guide

Introduction

In today's article we are going to create a similar interface with applications like Trello or ClickUp. The idea is to create a foundation so that you can extend the functionality of the app.

At the end of the article you will have a result similar to the following:

app example

What are we going to use?

Today we are not going to use many tools out of the ordinary, such as:

  • Stitches - a css-in-js styling library with phenomenal development experience
  • radash - a utility library that provides a set of functions that will help us deal with strings, objects and arrays
  • @dnd-kit/core - it's the library we're going to use to implement dnd, it's intuitive, lightweight and it's the new kid on the block

Bear in mind that although these are the libraries used in this article, the same result is also easily replicable with others.

Prerequisites

To follow this tutorial, you need:

  • Basic understanding of React
  • Basic understanding of TypeScript

You won't have any problem if you don't know TypeScript, you can always "ignore" the data types, however in today's example it makes the whole process much easier.

Getting Started

As a first step, create a project directory and navigate into it:

yarn create vite react-dnd --template react-ts
cd react-dnd
Enter fullscreen mode Exit fullscreen mode

Now we can install the necessary dependencies:

yarn add radash @dnd-kit/core @stitches/react @fontsource/anek-telugu
Enter fullscreen mode Exit fullscreen mode

With the boilerplate generated, we can start working on the application, but before we create the necessary components we need to pay attention to a few things.

First, let's create two primitives that will form the basis of two important components of today's example. The first primitive will be the Droppable.tsx, which is basically the area that will contain several elements that will be draggable.

// @/src/primitives/Droppable.tsx
import { FC, ReactNode, useMemo } from "react";
import { useDroppable } from "@dnd-kit/core";

interface IDroppable {
  id: string;
  children: ReactNode;
}

export const Droppable: FC<IDroppable> = ({ id, children }) => {
  const { isOver, setNodeRef } = useDroppable({ id });

  const style = useMemo(
    () => ({
      opacity: isOver ? 0.5 : 1,
    }),
    [isOver]
  );

  return (
    <div ref={setNodeRef} style={style}>
      {children}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

The other primitive that we will need is the Draggable.tsx that will be used in each of the elements that will be draggable.

// @/src/primitives/Draggable.tsx
import { FC, ReactNode, useMemo } from "react";
import { useDraggable } from "@dnd-kit/core";

interface IDraggable {
  id: string;
  children: ReactNode;
}

export const Draggable: FC<IDraggable> = ({ id, children }) => {
  const { attributes, listeners, setNodeRef, transform } = useDraggable({ id });

  const style = useMemo(() => {
    if (transform) {
      return {
        transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
      };
    }
    return undefined;
  }, [transform]);

  return (
    <div ref={setNodeRef} style={style} {...listeners} {...attributes}>
      {children}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

You may have noticed that the two primitives we created have a prop in common, which is the id. Obviously they reference the identifier, different use cases:

  • In the case of Droppable.tsx the id corresponds to the stage of the task (backlog, in progress, etc).
  • While in Draggable.tsx it corresponds to the element id (can be an integer or uuid).

Now that we have the primitives we can work on the components that will use them. Starting with the simplest component, called DraggableElement.tsx will be responsible for rendering the contents of the task.

// @/src/components/DraggableElement.tsx
import { FC, useMemo } from "react";
import { styled } from "@stitches/react";

import { Draggable } from "../primitives";

interface IDraggableElement {
  identifier: string;
  content: string;
}

export const DraggableElement: FC<IDraggableElement> = ({
  identifier,
  content,
}) => {
  const itemIdentifier = useMemo(() => identifier, [identifier]);

  return (
    <Draggable id={itemIdentifier}>
      <ElementWrapper>
        <ElementText>{content}</ElementText>
      </ElementWrapper>
    </Draggable>
  );
};

const ElementWrapper = styled("div", {
  background: "#f6f6f6",
  borderRadius: 10,
  height: 120,
  width: "100%",
  display: "flex",
  justifyContent: "center",
  alignItems: "center",
  marginTop: 12,
});

const ElementText = styled("h3", {
  fontSize: 18,
  fontWeight: 600,
});
Enter fullscreen mode Exit fullscreen mode

The last component we are going to create is the Column.tsx, this will be responsible for rendering each of the elements that are associated with a specific task.

Speaking of this component, there is a very important element called <DropPlaceholder /> which is a sensor, that is, it is this element that detects if the element being grabbed goes to a specific column/stage.

// @/src/components/Column.tsx
import { FC, useMemo } from "react";
import { styled } from "@stitches/react";
import * as _ from "radash";

import { Droppable } from "../primitives";
import { DraggableElement } from "./DraggableElement";

export interface IElement {
  id: string;
  content: string;
  column: string;
}

interface IColumn {
  heading: string;
  elements: IElement[];
}

export const Column: FC<IColumn> = ({ heading, elements }) => {
  const columnIdentifier = useMemo(() => _.camal(heading), [heading]);

  const amounts = useMemo(
    () => elements.filter((elm) => elm.column === columnIdentifier).length,
    [elements, columnIdentifier]
  );

  return (
    <ColumnWrapper>
      <ColumnHeaderWrapper variant={columnIdentifier as any}>
        <Heading>{heading}</Heading>
        <ColumnTasksAmout>{amounts}</ColumnTasksAmout>
      </ColumnHeaderWrapper>
      <Droppable id={columnIdentifier}>
        {elements.map((elm, elmIndex) => (
          <DraggableElement
            key={`draggable-element-${elmIndex}-${columnIdentifier}`}
            identifier={elm.id}
            content={elm.content}
          />
        ))}
        <DropPlaceholder />
      </Droppable>
    </ColumnWrapper>
  );
};

const Heading = styled("h3", {
  color: "#FFF",
});

const ColumnWrapper = styled("div", {
  width: 320,
  padding: 10,
  border: "dashed",
  borderWidth: 2,
  borderRadius: 10,
});

const DropPlaceholder = styled("div", {
  height: 35,
  backgroundColor: "transparent",
  marginTop: 15,
});

const ColumnHeaderWrapper = styled("div", {
  display: "flex",
  flexDirection: "row",
  justifyContent: "space-between",
  alignItems: "center",
  variants: {
    variant: {
      backlog: {
        background: "#F94892",
      },
      inProgress: {
        background: "#5800FF",
      },
      inReview: {
        background: "#ffb300",
      },
      done: {
        background: "#24A19C",
      },
    },
  },
  padding: "0px 10px 0px 10px",
  borderRadius: 10,
});

const ColumnTasksAmout = styled("span", {
  display: "flex",
  justifyContent: "center",
  alignItems: "center",
  width: 30,
  height: 30,
  borderRadius: 6,
  color: "#FFF",
  background: "rgba( 255, 255, 255, 0.25 )",
  boxShadow: "0 8px 32px 0 rgba( 255, 255, 255, 0.18 )",
  backdropFilter: "blur(5px)",
  border: "1px solid rgba( 255, 255, 255, 0.18 )",
});
Enter fullscreen mode Exit fullscreen mode

Last but not least, in App.tsx we will import the dependencies we need as well as define some variables and states related to the stages that we will have in the app.

Then we need to create a function called handleOnDragEnd() that will receive in the arguments the data related to the element that was dragged. In order to update it's state (for example to change the stage of inProgress to inReview).

Finally, we can map each of the columns that correspond to each of the stages and we will pass the elements associated with them in the props of each of these columns.

import "@fontsource/anek-telugu";
import { useCallback, useState } from "react";
import { DndContext, DragEndEvent } from "@dnd-kit/core";
import { styled } from "@stitches/react";
import * as _ from "radash";

import { Column, IElement } from "./components";

const COLUMNS = ["Backlog", "In Progress", "In Review", "Done"];
export const DEFAULT_COLUMN = "backlog";

const DEFAULT_DATA_STATE: IElement[] = [
  {
    id: _.uid(6),
    content: "Hello world 1",
    column: DEFAULT_COLUMN,
  },
  {
    id: _.uid(6),
    content: "Hello world 2",
    column: DEFAULT_COLUMN,
  },
];

export const App = () => {
  const [data, setData] = useState<IElement[]>(DEFAULT_DATA_STATE);

  const handleOnDragEnd = useCallback(
    ({ active, over }: DragEndEvent) => {
      const elementId = active.id;
      const deepCopy = [...data];

      const updatedState = deepCopy.map((elm): IElement => {
        if (elm.id === elementId) {
          const column = over?.id ? String(over.id) : elm.column;
          return { ...elm, column };
        }
        return elm;
      });

      setData(updatedState);
    },
    [data, setData]
  );

  return (
    <DndContext onDragEnd={handleOnDragEnd}>
      <MainWrapper>
        {COLUMNS.map((column, columnIndex) => (
          <Column
            key={`column-${columnIndex}`}
            heading={column}
            elements={_.select(
              data,
              (elm) => elm,
              (f) => f.column === _.camal(column)
            )}
          />
        ))}
      </MainWrapper>
    </DndContext>
  );
};

const MainWrapper = styled("div", {
  display: "flex",
  justifyContent: "space-evenly",
  backgroundColor: "#fff",
  paddingTop: 40,
  paddingBottom: 40,
  fontFamily: "Anek Telugu",
  height: "90vh",
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

As usual, I hope you enjoyed the article and that it helped you with an existing project or simply wanted to try it out.

If you found a mistake in the article, please let me know in the comments so I can correct it. Before finishing, if you want to access the source code of this article, I leave here the link to the github repository.

Top comments (1)

Collapse
 
obayomi96 profile image
Martins Obayomi

Great article Francisco, thanks!. Not sure if its a typo on your end but _camal(string) from the rodash library seems to be undefined, but _camel(string) seems to work to capitalise the string. Good read!.