How to build a drag and drop to- do list
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!
Member discussion