6 min read

How to build a drag and drop to- do list

💡
This is a tutorial that solves the "Build a drag and drop to-do list" exercise.
If you haven't already, try to solve it yourself first before reading the solution!

The component hierarchy

First step is to figure out the component structure.
Looking at the wireframe, we can see a few UI elements that appear multiple times: the task card and the task column - so these are great candidates for individual components. We will also need a parent component to manage the state and put everything together.

Displaying tasks as columns

Let's start by just displaying a list of todos in three columns, based on their status.
(This is the recommended way of thinking in react - first, data-down)

First, the ToDoList component would just pass the list of todos to the three column components:

const ToDoList = ({ todos }) => {
  return (
    <div style={{ display: "flex", flexDirection: "row" }}>
      <TaskColumn
        title="To do"
        todos={todos.filter((t) => t.status === "to-do")}
      />
      <TaskColumn
        title="In progress"
        todos={todos.filter((t) => t.status === "in-progress")}
      />
      <TaskColumn
        title="Done"
        todos={todos.filter((t) => t.status === "done")}
      />
    </div>
  );
};

Next, the column would just display its cards:

const TaskColumn = ({ title, todos }) => {
  return (
    <div
      style={{
        border: "1px solid gray",
        padding: "0 10px 10px 10px",
        margin: "10px",
        minWidth: "300px",
      }}
    >
      <h3>{title}</h3>
      <div>
        {todos.map((todo) => (
          <Card key={todo.id} todo={todo} />
        ))}
      </div>
    </div>
  );
};

Last, the Card can just display the text for now:

const Card = ({ todo }) => {
  return (
    <div
      style={{
        border: "1px solid lightgray",
        padding: "10px",
        margin: "5px 0",
      }}
    >
      {todo.text}
    </div>
  );
};

Dragging and dropping the cards

Next, we want to be able to drag and drop cards from one column to the other.

The requirements stated we should use the DnD Kit library, so let's first install it:

npm install @dnd-kit/core

The library works by defining "draggable" components, that you can drop over "droppable" components. You define a component as draggable or droppable using the useDraggable and useDroppable hooks respectively.

Following the getting started examples in their docs, here is how we can make the card draggable:

import { useDraggable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";

const Card = ({ todo }) => {
  const { attributes, listeners, setNodeRef, transform } = useDraggable({
    id: todo.id,
  });
  return (
    <div
      ref={setNodeRef}
      {...listeners}
      {...attributes}
      style={{
        border: "1px solid lightgray",
        padding: "10px",
        margin: "5px 0",
        backgroundColor: "lightblue",
        // Outputs `translate3d(x, y, 0)`
        transform: CSS.Translate.toString(transform),
      }}
    >
      {todo.text}
    </div>
  );
};

Then, to see it in action, we also need to wrap the whole draggable area with the DnDContext. In our case, let's add the context to the ToDoList component:

import { DndContext } from "@dnd-kit/core";
...

const ToDoList = ({ todos }) => {
  ...
  return (
    <DndContext>
      <div style={{ display: "flex", flexDirection: "row" }}>
        <TaskColumn
          title="To do"
          todos={todos.filter((t) => t.status === "to-do")}
        />
        ...
      </div>
    </DndContext>
  );
};

And it works! The card can now be dragged - but it's not very useful, as it can't be dropped anywhere 😅 We also need to add a background color, as now it just has a transparent background.

Making the TaskColumn droppable, also following their Getting Started guide, is a matter of using the useDroppable hook:

import Card from "./Card";
import { useDroppable } from "@dnd-kit/core";

const TaskColumn = ({ title, todos }) => {
  const { isOver, setNodeRef } = useDroppable({
    id: title,
  });

  return (
    <div
      ref={setNodeRef}
      style={{
        border: "1px solid gray",
        padding: "0 10px 10px 10px",
        margin: "10px",
        minWidth: "300px",
        backgroundColor: isOver ? "lavender" : "transparent",
      }}
    >
      <h3>{title}</h3>
      <div>
        {todos.map((todo) => (
          <Card key={todo.id} todo={todo} />
        ))}
      </div>
    </div>
  );
};

And indeed, the cards can now be dropped - and the user sees the background colour of the column changing as feedback.

Updating the task status on drop

So far, the app works great, but the changes don't get saved once you drop.

We can "catch" when the user drops a card using the onDragEnd event on the DndContext. Then, we can "remember" the changes by tracking the todos in React state. This is how the updated ToDoList will look:

import { useState } from "react";
import { DndContext } from "@dnd-kit/core";
import TaskColumn from "./TaskColumn";

const ToDoList = ({ todos: initialTodos }) => {
  const [todos, setTodos] = useState(initialTodos);

  const handleDragEnd = ({ active, over }) => {
    const draggedTodoId = active.id;
    const droppedColumnTitle = over.id;

    const statusByColumn = {
      "To do": "to-do",
      "In progress": "in-progress",
      Done: "done",
    };

    setTodos(
      todos.map((todo) => {
        if (todo.id === draggedTodoId) {
          return {
            ...todo,
            status: statusByColumn[droppedColumnTitle],
          };
        } else {
          return todo;
        }
      })
    );
  };

  return (
    <DndContext onDragEnd={handleDragEnd}>
      <div style={{ display: "flex", flexDirection: "row" }}>
        <TaskColumn
          title="To do"
          todos={todos.filter((t) => t.status === "to-do")}
        />
        <TaskColumn
          title="In progress"
          todos={todos.filter((t) => t.status === "in-progress")}
        />
        <TaskColumn
          title="Done"
          todos={todos.filter((t) => t.status === "done")}
        />
      </div>
    </DndContext>
  );
};

export default ToDoList;

And that's it! Users can now drag and drop todos.

Adding todos

Next, let's add the ability to add todos. We can achieve this by adding a simple text input with a button above the three columns.

We will need to generate an id for the new todo - I usually use an UUID, so let's install the uuid npm library:

npm install uuid

Here's the updated ToDoList component - notice the new form that was added, the new state variable for tracking the form value and the submit handler:

...
import { v4 as uuid } from "uuid";

const ToDoList = ({ todos: initialTodos }) => {
  const [todos, setTodos] = useState(initialTodos);
  const [newTodoText, setNewTodoText] = useState("");
  ....

  const handleAddTodo = (e) => {
    e.preventDefault();

    // add the todo
    setTodos([
      ...todos,
      {
        id: uuid,
        text: newTodoText,
        status: "to-do",
      },
    ]);
    // clear the input
    setNewTodoText("");
  };

  return (
    <DndContext onDragEnd={handleDragEnd}>
      <h2 style={{ marginLeft: "10px" }}>To do list</h2>
      <form
        onSubmit={handleAddTodo}
        style={{ margin: "10px", display: "flex", gap: "10px" }}
      >
        <input
          type="text"
          name="newTodoText"
          placeholder="type in your todo"
          value={newTodoText}
          onChange={(e) => setNewTodoText(e.target.value)}
        />
        <button type="submit">Add todo</button>
      </form>
      ...
    </DndContext>
  );
};

Deleting todos

As the useDroppable hook needs to be inside the DnDContext to work, let's create a dedicated component for the "drop to delete" area.

import { useDroppable } from "@dnd-kit/core";

const DropToDeleteArea = () => {
  const { isOver: isOverDeleteArea, setNodeRef: setDeleteAreaNodeRef } =
    useDroppable({
      id: "delete-task-area",
    });

  return (
    <div
      ref={setDeleteAreaNodeRef}
      style={{
        color: "gray",
        border: "1px solid gray",
        padding: "10px",
        margin: "10px",
        minHeight: "60px",
        backgroundColor: isOverDeleteArea ? "lavender" : "floralwhite",
      }}
    >
      Drop here to delete
    </div>
  );
};

export default DropToDeleteArea;

We will also need to update the code that handles the "drag end" event to check if task was dropped to a different column, or to the delete area:

const ToDoList = ({ todos: initialTodos }) => {
  ...

  const updateTodoStatus = (draggedTodoId, droppedColumnTitle) => {
    const statusByColumn = {
      "To do": "to-do",
      "In progress": "in-progress",
      Done: "done",
    };

    setTodos(
      todos.map((todo) => {
        if (todo.id === draggedTodoId) {
          return {
            ...todo,
            status: statusByColumn[droppedColumnTitle],
          };
        } else {
          return todo;
        }
      })
    );
  };

  const deleteTodo = (draggedTodoId) => {
    setTodos(todos.filter((todo) => todo.id !== draggedTodoId));
  };

  const handleDragEnd = ({ active, over }) => {
    if (!over) {
      // if user dropped the task outside any droppable area, return
      return;
    }
    const draggedTodoId = active.id;
    const droppedAreaId = over.id;
    if (droppedAreaId === "delete-task-area") {
      deleteTodo(active.id);
    } else {
      updateTodoStatus(draggedTodoId, droppedAreaId);
    }
  };
  ...

  return (
    <DndContext onDragEnd={handleDragEnd}>
      <h2 style={{ marginLeft: "10px" }}>To do list</h2>
      ....
      <DropToDeleteArea />
    </DndContext>
  );
};

You can check out the final completed app over at https://github.com/reactpractice-dev/drag-and-drop-todo-list/tree/solution (the "solution" branch of the main repo).

I hope you enjoyed this tutorial!
Share a link to your solution in the comments below! Also, don't hesitate to reach out if you have any questions.

Happy coding!