Challenge: Build a Task List
Course: React JS & Tailwind CSS - Full Course
Overview
Now that you’ve learned about state management, controlled components, rendering lists, conditional rendering, and forms, it’s time to put it all together in a mini-project that we can integrate into our main project!
The Goal
You will build a simple Task List app where a user can:
- Write a task into an input field.
- Add that task to a list.
- Render all tasks on the page.
- Add a checkbox to each task to toggle whether it’s completed.
- Add a way to edit a task after it’s been created.
- Add a button to remove tasks from the list.
This will help you practice form handling, state updates, and rendering lists dynamically.
We covered a lot of concepts, so take your time with this challenge. Try to give it a shot before looking at the solution.
Solution
Step 1: Setup the Input Field
Use useState
to manage the form data (in this case it's just to hold the task). Then create the input field UI and hook up the handleChange
logic. We can declare a handleSubmit
and work on the logic in the next step. This is what it should look like:
const [taskName, setTaskName] = useState("")
const handleChange = (e) => {
setTaskName(e.target.value)
}
return (
<form>
<label htmlFor="task-input" className="sr-only">
New Task
</label>
<input
type="text"
id="task-input"
className="border border-gray-200 rounded ml-2 px-2 py-1"
placeholder="Walk the dog"
onChange={handleChange}
value={taskName}
/>
<button className="ml-4 bg-blue-500 text-white rounded px-2 py-1 hover:bg-blue-600 hover:cursor-pointer">
Add Task
</button>
</form>
)
Step 2: Add Tasks to State
Create another piece of state to hold an array of tasks. Then implement logic within the handleSubmit
function to add the task to our array.
Remember, we want our task objects to have a unique id
so that we don't run into any issues when mapping over them. There are a few ways we can assign a unique id. For this, we will just use crypto.randomUUID()
which is a built in JavaScript method for generating ids.
And thinking a little further, we also want to include a key to indicate whether the task is completed or not. We also want to clear the input after submitting.
const handleCreate = (e) => {
e.preventDefault()
const newTask = {
id: crypto.randomUUID(),
name: taskName.trim(),
isComplete: false,
}
setTaskList([...taskList, newTask])
setTaskName("")
}
Don't forget to hook the handleCreate
function up to the form!
Step 3: Render the Tasks
Now that we have a way to create the tasks, we need to actually render the tasks. We won't add any functionality just yet, so we'll just create the layout and buttons for the tasks.
This should be pretty straightforward, we take the taskList
array and map over each object in the task list to create a task card:
{taskList.map((task) => (
<div
key={task.id}
className="border border-gray-200 p-4 rounded-md shadow"
>
<div className="flex items-center">
<p>{task.name}</p>
<button
type="button"
className="ml-auto cursor-pointer rounded-full flex items-center justify-center w-6 h-6 border border-gray-200 hover:bg-gray-100 text-gray-500"
>
✓
</button>
</div>
<div className="h-px bg-gray-200 my-4"></div>
<div className="flex ml-auto">
<button
type="button"
className="ml-auto bg-red-500 text-white cursor-pointer hover:bg-red-600 px-2 py-1 rounded"
>
Delete
</button>
<button
type="button"
className="ml-2 px-2 py-1 rounded border border-gray-200 hover:bg-gray-100 cursor-pointer"
>
Edit
</button>
</div>
</div>
))}
Step 4: Implement Complete Task Toggle
Let's brainstorm real quick how we'll toggle the status of each task. Well, to toggle a task, we need to know which task it is we want to toggle. One way to identify that is with the id
of that task.
We'll create a function that takes an id
as the argument. From here, there's a few different ways you can go about this. You can find the index of the object with the matching id, create a copy of that array and update the object you want to edit, and then set state with the new array. Or you can do this:
function toggleComplete(id) {
setTaskList((prevList) =>
prevList.map((task) =>
task.id === id ? { ...task, isComplete: !task.isComplete } : task
)
)
}
So what's happening is we're calling setTaskList
, which takes the previous list prevList
. We map over each task in prevList
and compare it's id with the id that we passed in. If it matches, then we update the task by creating a shallow copy …task
and flipping the boolean of isComplete
.
If it is not the task we're looking for, then we just return the original task unchanged. Then hook the function up to the toggle button:
onClick={() => toggleComplete(task.id)}
The reason we want to wrap it in an arrow function is because we want this to be a function definition, and not a function call. We only want to call this function when it is clicked, not when React renders the component.
You may have noticed that there is no visual cue when we toggle the button even though state is updating. We need some way to conditionally render a new UI to indicate the button has been toggled. Fortunately we can conditionally render classNames in React by using curly braces and template literals:
<button
type="button"
onClick={() => toggleComplete(task.id)}
className={`ml-auto cursor-pointer rounded-full flex items-center justify-center w-6 h-6 border ${
task.isComplete
? `border-none bg-blue-500 hover:bg-blue-600 text-white`
: `border-gray-200 hover:bg-gray-100 text-gray-500`
}`}
>
✓
</button>
We dynamically generate the classNames based on the value of isComplete.
Step 5: Delete A Task
Since the logic for deleting a task is similar to toggling a task, we'll work on this first (especially since updating is a bit more complicated). We simply need to find the task with a matching id, and filter it out. This can be done using the JavaScript's .filter()
method:
function handleDelete(id) {
setTaskList((prevList) => prevList.filter((task) => task.id !== id))
}
Step 6: Edit A Task
This part is a little trickier. To edit a task, we'll need to dynamically render an input field, and use that value to update the corresponding task inside the task list array. There's a few different ways to go about this, but the cleaner approach is to create a TaskItem
component that can take props and hold its own state. We'll first have to refactor our code a little bit:
function TaskItem({ task, toggleComplete, handleDelete }) {
return (
<div key={task.id} className="border border-gray-200 p-4 rounded-md shadow">
<div className="flex items-center">
<p>{task.name}</p>
<button
type="button"
onClick={() => toggleComplete(task.id)}
className={`ml-auto cursor-pointer rounded-full flex items-center justify-center w-6 h-6 border ${
task.isComplete
? `border-none bg-blue-500 hover:bg-blue-600 text-white`
: `border-gray-200 hover:bg-gray-100 text-gray-500`
}`}
>
✓
</button>
</div>
<div className="h-px bg-gray-200 my-4"></div>
<div className="flex ml-auto">
<button
type="button"
onClick={() => handleDelete(task.id)}
className="ml-auto bg-red-500 text-white cursor-pointer hover:bg-red-600 px-2 py-1 rounded"
>
Delete
</button>
<button
type="button"
className="ml-2 px-2 py-1 rounded border border-gray-200 hover:bg-gray-100 cursor-pointer"
>
Edit
</button>
</div>
</div>
)
}
And update the map statement to:
{taskList.map((task) => (
<TaskItem
task={task}
toggleComplete={toggleComplete}
handleDelete={handleDelete}
/>
))}
Now within the TaskItem
component we can create state to indicate whether to show the input element or not, and also to hold the value of that input element:
const [isEditing, setIsEditing] = useState(false)
const [editedTaskName, setEditedTaskName] = useState(task.name)
Then, instead of defining a function to change isEditing to true and passing it to the edit button, we can just define it within the onClick like this:
onClick={() => setIsEditing(true)}
Edit Task UI
Now let's go ahead and actually build the UI to edit the task. We'll add the functionality to the buttons later:
<div>
<div className="flex items-center">
<input
type="text"
value={editedTaskName}
onChange={(e) => setEditedTaskName(e.target.value)}
className="w-full border border-gray-200 rounded px-2 py-1"
/>
</div>
<div className="h-px bg-gray-200 my-4"></div>
<div className="flex">
<button
type="button"
className="ml-auto bg-slate-500 text-white cursor-pointer hover:bg-slate-600 px-2 py-1 rounded"
>
Save
</button>
<button
type="button"
onClick={() => setIsEditing(true)}
className="ml-2 px-2 py-1 rounded border border-gray-200 hover:bg-gray-100 cursor-pointer"
>
Cancel
</button>
</div>
</div>
Implementing Functionality to Edit Task
The "edit task" UI is set up, but we only want to render it when isEditing
is true. To do that we'll have to use a ternary:
{isEditing ? (/*Editing UI Here*/): (/*Task UI Here*/)}
Essentially, we'll render the editing UI if isEditing is true, and the task UI if false. Now let's add functionality to the "cancel" button to hide the editing UI.
onClick={() => setIsEditing(false)}
Updating the Task
To update the task, we'll need to create a function for that. Unlike the delete and toggle functions, our update function will need to take 2 arguments. One to identify the task being updated, and the value to update the task to.
const updateTask = (id, taskValue) => {
setTaskList((prevList) =>
prevList.map((task) =>
task.id === id ? { ...task, name: taskValue } : task
)
)
}
Something to note, state in a parent component cannot be altered by a child component. So we need to define the function in the parent component. For state to be shared across components, it need to be "lifted" to a common parent component, and passed to the child components.
After defining the function, don't forget to pass it into the TaskItem
component and hook it up into the button:
onClick={() => {
updateTask(task.id, editedTaskName)
setIsEditing(false)
}}
Remember to close the editing UI after updating the task, otherwise the editing UI will remain open even after update.
And that'll do it! You've created the core functionality of a task tracking app that allows users to create, update, and delete tasks. This app is nearly complete. There's only a few features left to implement before turning this into a fully functional task tracker.