So far, we've explored all the key concepts of React, including JSX, components, props, state management, reducers, context, refs, and effects. We also delved into creating custom hooks. Now, it's time to put these skills into practice. In this lesson, we'll build a todo list using React, allowing us to reinforce everything we've learned.
Our todo list will have the following features:
- Add a new task
- Delete a task
- Edit a task
- Mark a task as complete
- Filter the task list to view only the pending tasks
Creating a new React project
First of all, let's initialize a new React project. Go to your work directory and run the following command:
1npm create vite@latest <my_project>
This command allows you to initialize a project using Vite. You will be prompted to select a framework. Navigate using the ↑ and ↓ keys, select React and hit Enter
.
1? Select a framework: › - Use arrow-keys. Return to submit.
2 Vanilla
3 Vue
4❯ React
5 Preact
6 Lit
7 Svelte
8 Solid
9 Qwik
10 Others
Next, Vite will ask if you opt to use TypeScript and SWC (Speedy Web Compiler). For now, let's stick to JavaScript and SWC.
1? Select a variant: › - Use arrow-keys. Return to submit.
2 TypeScript
3 TypeScript + SWC
4 JavaScript
5❯ JavaScript + SWC
6 Remix ↗
Lastly, you should see the following output:
1✔ Select a framework: › React
2✔ Select a variant: › JavaScript + SWC
3
4Scaffolding project in /. . ./reactjs-demo/reactjs-demo...
5
6Done. Now run:
7
8 cd <my_project>
9 npm install
10 npm run dev
A new project should be generated. Change into the project directory, install the necessary dependencies, and finally start the dev server.
1cd <my_project>
1npm install
1npm run dev
Adding new tasks
Open demo in new tabTo get started, let's think about how you can add a new task.
First of all, we need an input box. This input box is associated with a state variable text
, which is used to store user input.
When the Add button is clicked, an event handler will be triggered, pushing the text to another state variable todos
.
Updating todos
will trigger a rerender, causing the associated list of tasks to update.
To implement this logic, create a TodoList.jsx
file under components
.
1src
2├── App.jsx
3├── assets
4├── components
5│ └── TodoList.jsx <===
6├── index.css
7├── libs
8└── main.jsx
The input box
Let's start with the <input>
box:
components/TodoList.jsx
1import { useState } from "react";
2
3function TodoList() {
4 const [text, setText] = useState("");
5
6 return (
7 <>
8 <input value={text} onChange={(e) => setText(e.target.value)} />
9 </>
10 );
11}
12
13export default TodoList;
The <input>
is associated with the state text
. When the user types in the input box, the onChange
event will be triggered. The associated handler will update the text with the user input.
1(e) => setText(e.target.value);
The Add button
Next, let's create the Add button:
components/TodoList.jsx
1import { useState } from "react";
2
3let nextId = 0;
4
5function TodoList() {
6 const [todos, setTodos] = useState([]);
7 const [text, setText] = useState("");
8
9 function handleAdd() {
10 if (!text.trim()) return;
11 setTodos([...todos, { id: nextId++, text, completed: false }]);
12 setText("");
13 }
14
15 return (
16 <>
17 <input value={text} onChange={(e) => setText(e.target.value)} />
18 <button onClick={handleAdd}>Add</button>
19 </>
20 );
21}
22
23export default TodoList;
The button is associated with the event handler handleAdd
. We need to take a closer look at this function:
1function handleAdd() {
2 if (!text.trim()) return;
3 setTodos([...todos, { id: nextId++, text, completed: false }]);
4 setText("");
5}
First, !text.trim()
tests if the user input is empty, or contains only spaces. The trim()
method is used to remove spaces from from both ends of a string. For example, the following code will output Hello, World!
.
1let str = " Hello, World! ";
2let trimmedStr = str.trim();
3console.log(trimmedStr);
If the string contains only spaces, an empty string will be returned. The logical NOT operator (!
) will convert the empty string, a falsy value, to true
, and finally the empty return statement will end the handler function without updating text
and todos
.
After making sure the user input (text
) is not empty, you can use text
to update todos
. The spread syntax (...todos
) ensures that all existing todo items are preserved, while the new todo is appended to the list.
1setTodos([...todos, { id: nextId++, text, completed: false }]);
Notice that besides the text
, we also have an id
and completed
property for each todo item. We are going to use these properties for other features. Let's discuss more about them when we get there.
After updating todos
, reset text
to an empty string.
1setText("");
The task list
Finally, use the todos
to create a list of todo items like this:
components/TodoList.jsx
1import { useState } from "react";
2
3let nextId = 0;
4
5function TodoList() {
6 const [todos, setTodos] = useState([]);
7 const [text, setText] = useState("");
8
9 function handleAdd() {
10 if (!text.trim()) return;
11 setTodos([...todos, { id: nextId++, text, completed: false }]);
12 setText("");
13 }
14
15 return (
16 <>
17 <input value={text} onChange={(e) => setText(e.target.value)} />
18 <button onClick={handleAdd}>Add</button>
19 <ul>
20 {todos.map((todo) => (
21 <li key={todo.id}>{todo.text}</li>
22 ))}
23 </ul>
24 <footer>{todos.length} todos</footer>
25 </>
26 );
27}
28
29export default TodoList;
Remove tasks
Open demo in new tabTo remove a task, we need to modify the user interface for the list, so that for each item, there is a corresponding Remove button. Clicking on this button will trigger the handleRemove
event handler, removing the item based on its id
. For example,
components/TodoList.jsx
1import { useState } from "react";
2
3let nextId = 0;
4
5function TodoList() {
6 const [todos, setTodos] = useState([]);
7 const [text, setText] = useState("");
8
9 function handleAdd() {
10 if (!text.trim()) return;
11 setTodos([...todos, { id: nextId++, text }]);
12 setText("");
13 }
14
15 // The handleRemove accepts an id and removes the todo item with that id
16 function handleRemove(id) {
17 setTodos(todos.filter((todo) => todo.id !== id));
18 }
19
20 return (
21 <>
22 <input value={text} onChange={(e) => setText(e.target.value)} />
23 <button onClick={handleAdd}>Add</button>
24 <ul>
25 {todos.map((todo) => (
26 <li key={todo.id}>
27 {todo.text}
28 <button onClick={() => handleRemove(todo.id)}>Remove</button>
29 </li>
30 ))}
31 </ul>
32 <footer>{todos.length} todos</footer>
33 </>
34 );
35}
36
37export default TodoList;
Let's take a look at the event handler handleRemove
:
1function handleRemove(id) {
2 setTodos(todos.filter((todo) => todo.id !== id));
3}
The filter()
method is used to search for elements in an array that matches a certain condition, which is described by a test function. For instance, the following example returns all the array elements that are greater than 50.
1let arr = [23, -5, 667, 150, -3, 60, 17, -69];
2
3let answer = arr.filter(testFunction);
4
5function testFunction(value, index, array) {
6 return value > 50;
7}
8
9console.log(answer);
1[ 667, 150, 60 ]
For our handleRemove
event handler, the test function is:
1(todo) => todo.id !== id;
Which means the filter()
will go through all the todo items and check their id
(todo.id
). If the id
does not match the one passed to handleRemove
, that todo item will pass the test and be preserved in the new todos
.
This leads to a new problem. Since we need to pass the id
to the event handler, we need to use the syntax handleRemove(todo.id)
. However, this syntax will also execute the function immediately after render.
As a work around, instead of calling the handler directly, pass a callback function like this:
1<button onClick={() => handleRemove(todo.id)}>Remove</button>
Edit existing tasks
Open demo in new tabEditing a task takes a bit more work compared to removing one. First of all, there should be an Edit button. When the button is clicked, the todo item turns into an input box, with the corresponding Save and Cancel buttons.
This input box should be associated with a editText
state, which works similarly to text
.
Let's divide this logic into two parts, and first talk about how to display the input box when the Edit button is clicked. To achieve this, you need another state variable editId
, which we are going to use to track the id
of the todo item being edited.
components/TodoList.jsx
1import { useState } from "react";
2
3let nextId = 0;
4
5function TodoList() {
6 const [todos, setTodos] = useState([]);
7 const [text, setText] = useState("");
8 const [editId, setEditId] = useState(null); // Track the ID of the todo being edited
9 const [editText, setEditText] = useState("");
10
11 . . .
12
13 function startEdit(todo) {
14 setEditId(todo.id);
15 setEditText(todo.text);
16 }
17
18 return (
19 <>
20 <input value={text} onChange={(e) => setText(e.target.value)} />
21 <button onClick={handleAdd}>Add</button>
22 <ul>
23 {todos.map((todo) => (
24 <li key={todo.id}>
25 {editId === todo.id ? (
26 <>
27 <input
28 value={editText}
29 onChange={(e) => setEditText(e.target.value)}
30 />
31 <button onClick={() => handleEdit(todo.id, editText)}>
32 Save
33 </button>
34 <button onClick={() => setEditId(null)}>Cancel</button>
35 </>
36 ) : (
37 <>
38 {todo.text}
39 <button onClick={() => startEdit(todo)}>Edit</button>
40 <button onClick={() => handleRemove(todo.id)}>Remove</button>
41 </>
42 )}
43 </li>
44 ))}
45 </ul>
46 <footer>{todos.length} todos</footer>
47 </>
48 );
49}
50
51export default TodoList;
At first, editId
will be initialized as null
, which means editId === todo.id
in line 25 will give false
for every todo item, and the following JSX will be rendered:
1<>
2 {todo.text}
3 <button onClick={() => startEdit(todo)}>Edit</button>
4 <button onClick={() => handleRemove(todo.id)}>Remove</button>
5</>
When the Edit button is clicked, startEdit(todo)
will be executed. Again, let's take a closer look at this handler function:
1function startEdit(todo) {
2 setEditId(todo.id);
3 setEditText(todo.text);
4}
Both editId
amd editText
will be updated, and the component will be rerendered with the new value. And this time, the todo item whose id
matches editId
will be rendered as:
1<>
2 <input value={editText} onChange={(e) => setEditText(e.target.value)} />
3 <button onClick={() => handleEdit(todo.id, editText)}>Save</button>
4 <button onClick={() => setEditId(null)}>Cancel</button>
5</>
This task editing interface requires another event handler, handleEdit
:
components/TodoList.jsx
1function handleEdit(id, newText) {
2 setTodos(
3 todos.map((todo) =>
4 todo.id === id ? { ...todo, text: newText, completed: false } : todo
5 )
6 );
7 setEditId(null); // Close the edit mode after saving
8}
This handler function update todos
similar to handleAdd
we discussed before. Remember to reset editId
to null
at the end.
Splitting into multiple components
Open demo in new tabAs you can see, our TodoList
is getting too complicated. This would a good time to split TodoList
into different components so that each component is only focused on one thing.
For example, our todo list could be split into a NewTodo
in charge of adding new items, and a TodoItem
in charge of displaying a todo item, as well as editing and deleting that item. And finally, the TodoList
will combine them together.
1src
2├── App.jsx
3├── assets
4│ └── react.svg
5├── components
6│ ├── NewTodo.jsx
7│ ├── TodoItem.jsx
8│ └── TodoList.jsx
9├── index.css
10├── libs
11└── main.jsx
components/NewTodo.jsx
1import { useState } from "react";
2
3function NewTodo({ onAdd }) {
4 const [text, setText] = useState("");
5
6 function handleAdd() {
7 if (!text.trim()) return;
8 onAdd(text);
9 setText("");
10 }
11
12 return (
13 <>
14 <input value={text} onChange={(e) => setText(e.target.value)} />
15 <button onClick={handleAdd}>Add</button>
16 </>
17 );
18}
19
20export default NewTodo;
components/TodoItem.jsx
1import { useState } from "react";
2
3function TodoItem({ todo, onEdit, onRemove }) {
4 const [isEditing, setIsEditing] = useState(false);
5 const [editText, setEditText] = useState(todo.text);
6
7 function handleSave() {
8 if (!editText.trim()) return;
9 onEdit(todo.id, editText);
10 setIsEditing(false);
11 }
12
13 return (
14 <li>
15 {isEditing ? (
16 <>
17 <input
18 value={editText}
19 onChange={(e) => setEditText(e.target.value)}
20 />
21 <button onClick={handleSave}>Save</button>
22 <button onClick={() => setIsEditing(false)}>Cancel</button>
23 </>
24 ) : (
25 <>
26 {todo.text}
27 <button onClick={() => setIsEditing(true)}>Edit</button>
28 <button onClick={() => onRemove(todo.id)}>Remove</button>
29 </>
30 )}
31 </li>
32 );
33}
34
35export default TodoItem;
components/TodoList.jsx
1import { useState } from "react";
2import NewTodo from "./NewTodo";
3import TodoItem from "./TodoItem";
4
5let nextId = 0;
6
7function TodoList() {
8 const [todos, setTodos] = useState([]);
9
10 function handleAdd(text) {
11 setTodos([...todos, { id: nextId++, text }]);
12 }
13
14 function handleRemove(id) {
15 setTodos(todos.filter((todo) => todo.id !== id));
16 }
17
18 function handleEdit(id, newText) {
19 setTodos(
20 todos.map((todo) => (todo.id === id ? { ...todo, text: newText } : todo))
21 );
22 }
23
24 return (
25 <>
26 <NewTodo onAdd={handleAdd} />
27 <ul>
28 {todos.map((todo) => (
29 <TodoItem
30 key={todo.id}
31 todo={todo}
32 onEdit={handleEdit}
33 onRemove={handleRemove}
34 />
35 ))}
36 </ul>
37 <footer>{todos.length} todos</footer>
38 </>
39 );
40}
41
42export default TodoList;
Complete a task
Open demo in new tabSince we are creating a todo list, so naturally, the user should be able to mark a task as complete. To do this, we need to make some modifications to our app.
First, go to the TodoList
component and create a handleToggleComplete
event handler. Every time this handler is called, it will toggle the complete
property of the corresponding todo item.
components/TodoList.jsx
1import { useState } from "react";
2import NewTodo from "./NewTodo";
3import TodoItem from "./TodoItem";
4
5let nextId = 0;
6
7function TodoList() {
8 const [todos, setTodos] = useState([]);
9
10 . . .
11
12 function handleToggleComplete(id) {
13 setTodos(
14 todos.map((todo) =>
15 todo.id === id ? { ...todo, completed: !todo.completed } : todo
16 )
17 );
18 }
19
20 return (
21 <>
22 <NewTodo onAdd={handleAdd} />
23 <ul>
24 {todos.map((todo) => (
25 <TodoItem
26 key={todo.id}
27 todo={todo}
28 onEdit={handleEdit}
29 onRemove={handleRemove}
30 onToggleComplete={handleToggleComplete}
31 />
32 ))}
33 </ul>
34 <footer>{todos.length} todos</footer>
35 </>
36 );
37}
38
39export default TodoList;
The reason we are placing this event handler inside TodoList
, the parent component, is because it needs to have access to the state todos
. You can then pass this handler down to the child component TodoItem
.
And inside TodoItem
, this event handler is tied to another button element, from line 32 to 34.
components/TodoItem.jsx
1import { useState } from "react";
2
3function TodoItem({ todo, onEdit, onRemove, onToggleComplete }) {
4 const [isEditing, setIsEditing] = useState(false);
5 const [editText, setEditText] = useState(todo.text);
6
7 function handleSave() {
8 if (!editText.trim()) return;
9 onEdit(todo.id, editText);
10 setIsEditing(false);
11 }
12
13 return (
14 <li>
15 {isEditing ? (
16 <>
17 <input
18 value={editText}
19 onChange={(e) => setEditText(e.target.value)}
20 />
21 <button onClick={handleSave}>Save</button>
22 <button onClick={() => setIsEditing(false)}>Cancel</button>
23 </>
24 ) : (
25 <>
26 <span
27 style={{
28 textDecoration: todo.completed ? "line-through" : "none",
29 }}>
30 {todo.text}
31 </span>
32 <button onClick={() => onToggleComplete(todo.id)}>
33 {todo.completed ? "Undo" : "Complete"}
34 </button>
35 <button onClick={() => setIsEditing(true)}>Edit</button>
36 <button onClick={() => onRemove(todo.id)}>Remove</button>
37 </>
38 )}
39 </li>
40 );
41}
42
43export default TodoItem;
This button displays either Undo or Complete, depending on the truthiness of todo.completed
.
Similarly, from line 26 to 31, a line-through
text decoration will be displayed depending on whether or not the task is completed.
View pending tasks only
Open demo in new tabFinally, before we wrap up this tutorial, let's create a filter so that our todo list can display only the pending tasks. Go to TodoList
and initialize a new state variable, showPendingOnly
, as well as a checkbox that toggles its value.
components/TodoList.jsx
1import { useState } from "react";
2import NewTodo from "./NewTodo";
3import TodoItem from "./TodoItem";
4
5let nextId = 0;
6
7function TodoList() {
8 const [todos, setTodos] = useState([]);
9 const [showPendingOnly, setShowPendingOnly] = useState(false);
10
11 . . .
12
13 return (
14 <>
15 <NewTodo onAdd={handleAdd} />
16 <label>
17 <input
18 type="checkbox"
19 checked={showPendingOnly}
20 onChange={() => setShowPendingOnly(!showPendingOnly)}
21 />
22 Show pending tasks only
23 </label>
24 . . .
25 <footer>{filteredTodos.length} tasks displayed</footer>
26 </>
27 );
28}
29
30export default TodoList;
Recall that previously, we used todos.map()
to render a list of todo items. But now, we'll need to add a filter for it:
1import { useState } from "react";
2import NewTodo from "./NewTodo";
3import TodoItem from "./TodoItem";
4
5let nextId = 0;
6
7function TodoList() {
8 const [todos, setTodos] = useState([]);
9 const [showPendingOnly, setShowPendingOnly] = useState(false);
10
11 . . .
12
13 // Filter todos based on the "showPendingOnly" state
14 const filteredTodos = showPendingOnly
15 ? todos.filter((todo) => !todo.completed)
16 : todos;
17
18 return (
19 <>
20 <NewTodo onAdd={handleAdd} />
21 <label>
22 <input
23 type="checkbox"
24 checked={showPendingOnly}
25 onChange={() => setShowPendingOnly(!showPendingOnly)}
26 />
27 Show pending tasks only
28 </label>
29 <ul>
30 {filteredTodos.map((todo) => (
31 <TodoItem
32 key={todo.id}
33 todo={todo}
34 onEdit={handleEdit}
35 onRemove={handleRemove}
36 onToggleComplete={handleToggleComplete}
37 />
38 ))}
39 </ul>
40 <footer>{filteredTodos.length} tasks displayed</footer>
41 </>
42 );
43}
44
45export default TodoList;
In this case, if showPendingOnly
is true
, todos.filter((todo) => !todo.completed)
will return an array of tasks whose completed
properties are false
.
If showPendingOnly
is false
, todos
will be assigned to filteredTodos
directly.
And finally, instead of using todos.map()
, you should use filteredTodos.map()
to render the list.