We will build THE classic React app: a TodoList. It’s a simple app and yet full of interesting React and JavaScript concepts.
We will cover:
- Creating functional React components and managing their state with hooks.
- Handling conditional rendering in React.
- Using PropTypes for a safer code.
- JavaScript concepts: the ternary operator, array methods, and the spread operator.
- Refactoring code to reduce code redundancy and increase code readability.
You can find the final source code here and the live version here.
The app requirements
The app allows us to perform the following operations:
- Add a todo (it gets added to the Pending section).
- Complete a todo (it gets added to the Completed section).
- Delete a todo (whether it’s pending or completed).
User Interface Mock
Here’s a simple sketch of the UI of our application:
As you can see, our UI consists of the following parts:
- Text input field where the user types a todo.
- Pending section: contains the todos added by the user that have not been completed yet.
- Completed section: contains the todos completed by the user.
Project Setup
We use create-react-app CLI tool to bootstrap our project:
npx create-react-app todo-app
This gives us the necessary boilerplate to immediately get productive and not worry about any build tools.
We’re now ready for implementation.
Note: to keep this article short and focused, we will not get into details about the CSS part of the app. To have a similar look to the final result, make sure to copy the same code in App.css file and use the same classNames.
Adding a heading with the App title
Let’s delete the boilerplate code in App.js and add a header that contains the title of our todo app:
import React from "react";
import "./App.css";
function App() {
return (
<div className="app">
<h1>Todo</h1>
</div>
);
}
export default App;
Adding the Input field
Let's add a basic text input element for the user to type a todo.
To keep track of the value in the input field, we need to save and update that value whenever the user types-in something.
In React we store our application data in the app state. To manage our text input state, we use React.useState
hook.
Note: You can find a detailed article about handling state with React.useState hook here.
We can import the useState
function from React as follows:
import React, { useState } from "react";
The typed-in todo by the user has a text value. Therefore, let’s initialize it with an empty string:
const [typedInTodo, setTypedInTodo] = useState("");
useState
provides an array that contains 2 elements:
- A
typedInTodo
value that we can use to populate theinput
field. - A
setTypedInTodo
function to update the todo. We’ll see later how to do that.
import React, { useState } from "react";
import "./App.css";
function App() {
const [typedInTodo, setTypedInTodo] = useState("");
return (
<div className="app">
<h1>Todo</h1>
<input type="text" placeholder="Add todo..." value={typedInTodo} />
</div>
);
}
export default App;
Handling changes to the input
If we try to type anything in the input field, we’ll notice that it stays empty. This is because we‘re not updating its value based on what the user is typing.
To react to the user typing, we add an onChange
event listener to the input field.
Our event listener receives an event
parameter that we can use to extract the typed-in value and update the state with it:
onChange={(event) => setTypedInTodo(event.target.value)}
import React, { useState } from "react";
import "./App.css";
function App() {
const [typedInTodo, setTypedInTodo] = useState("");
return (
<div className="app">
<h1>Todo</h1>
<input
type="text"
placeholder="Add todo..."
value={typedInTodo}
onChange={(event) => setTypedInTodo(event.target.value)}
/>
</div>
);
}
export default App;
Now if we type something in the input field, it gets updated. We can check if the state is updated by outputting the new value of typedInTodo
to the console:
console.log(typedInTodo)
Let’s make it possible to submit a todo
Since the pending section will hold the submitted todos, it has to have its own state to store such data. We define its state, similarly to how we did it for the typed-in todo, using React.useState
. Since it’s a list, we need an array to store this data. Initially, it is an empty array:
const [pendingTodos, setPendingTodos] = useState([]);
import React, { useState } from "react";
import "./App.css";
function App() {
const [typedInTodo, setTypedInTodo] = useState("");
const [pendingTodos, setPendingTodos] = useState([]);
return (
<div className="app">
<h1>Todo</h1>
<input
type="text"
placeholder="Add todo..."
value={typedInTodo}
onChange={(event) => setTypedInTodo(event.target.value)}
/>
</div>
);
}
export default App;
Let’s now make it possible to add a todo to the pending section by hitting the “Enter” key.
We can do so by adding the onKeyDown
event listener to the input field. The listener function adds the typed-in todo to the pending section if the following conditions hold:
- The user pressed the “Enter” key.
- The typed-in todo is not empty. To remove spaces when checking that, we can use
String.prototype.trim()
method.
To add the todo to the pendingTodos
, we use the Spread operator, which allows us to expand the array into individual elements and add a new todo to it.
We should also not forget to clear the input field once the todo is submitted. We can do so by updating the typedInTodo
to an empty string.
import React, { useState } from "react";
import "./App.css";
function App() {
const [typedInTodo, setTypedInTodo] = useState("");
const [pendingTodos, setPendingTodos] = useState([]);
function onKeyDown(e) {
if (e.key === "Enter" && typedInTodo.trim()) {
setPendingTodos([...pendingTodos, typedInTodo]);
setTypedInTodo("");
}
}
return (
<div className="app">
<h1>Todo</h1>
<input
type="text"
placeholder="Add todo..."
value={typedInTodo}
onChange={(event) => setTypedInTodo(event.target.value)}
onKeyDown={onKeyDown}
/>
</div>
);
}
export default App;
Let's see if our logic works properly by adding some logs:
console.log(typedInTodo);
console.log(pendingTodos);
Note: if your console is logging values twice then you need to remove the StrictMode element from index.js file.
Showing the Pending todos
The todos submitted by the user are stored in the state, but we can’t see them in the UI yet.
Let’s add a “Pending” section to show these todos.
The pending section has:
- Title: an 'h2' header named "Pending" that appears dimmed when the section is empty and appears bold whenever a todo is added.
- List: contains the current pending todos.
We use Array.prototype.map
method to map each pending todo in pendingTodos
to a div that has:
- The text of the todo.
- A button to complete the todo.
- A button to delete the todo.
import React, { useState } from "react";
import { CloseOutlined, CheckOutlined } from "@ant-design/icons";
import "./App.css";
function App() {
const [typedInTodo, setTypedInTodo] = useState("");
const [pendingTodos, setPendingTodos] = useState([]);
function onKeyDown(e) {
if (e.key === "Enter" && typedInTodo.trim()) {
setPendingTodos([...pendingTodos, typedInTodo]);
setTypedInTodo("");
}
}
return (
<div className="app">
<h1>Todo</h1>
<input
type="text"
placeholder="Add todo..."
value={typedInTodo}
onChange={(event) => setTypedInTodo(event.target.value)}
onKeyDown={onKeyDown}
/>
<div className="sectionsContainer">
<div className="todoContainer">
<h2
className={
pendingTodos.length > 0
? "boldSectionTitle"
: "dimmedSectiontTitle"
}
>
Pending
</h2>
<div>
{pendingTodos.map((todo, index) => (
<div key={index} className="todoItem">
<p>{todo}</p>
<div className="buttonsSection">
<button className="transparent completeButton">
<CheckOutlined className="icon" />
</button>
<button className="transparent deleteButton">
<CloseOutlined className="icon" />
</button>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
export default App;
Note: your console will inform you that every rendered todo task in our components needs its own key attribute to identify it from its siblings and that is why we added such attribute to the todos.
Adding the delete ❌ functionality
Let's make the delete button work. Our delete function:
- Gets the index of the todo to delete.
- Filters out the todo from the current list of pending todos, by removing any todo that has the same index as the target one.
Note: the filter function passed to
Array.prototype.filter
accepts 2 arguments: the todo itself (which we don't need and therefore we labeled it as_
) and the index of the todo.
import React, { useState } from "react";
import { CloseOutlined, CheckOutlined } from "@ant-design/icons";
import "./App.css";
function App() {
const [typedInTodo, setTypedInTodo] = useState("");
const [pendingTodos, setPendingTodos] = useState([]);
function deleteTodo(todoIndex) {
const filteredTodos = pendingTodos.filter(
(_, index) => todoIndex !== index
);
setPendingTodos(filteredTodos);
}
function onKeyDown(e) {
if (e.key === "Enter" && typedInTodo.trim()) {
setPendingTodos([...pendingTodos, typedInTodo]);
setTypedInTodo("");
}
}
return (
<div className="app">
<h1>Todo</h1>
<input
type="text"
placeholder="Add todo..."
value={typedInTodo}
onChange={(event) => setTypedInTodo(event.target.value)}
onKeyDown={onKeyDown}
/>
<div className="sectionsContainer">
<div className="todoContainer">
<h2
className={
pendingTodos.length > 0
? "boldSectionTitle"
: "dimmedSectiontTitle"
}
>
Pending
</h2>
<div>
{pendingTodos.map((todo, index) => (
<div key={index} className="todoItem">
<p>{todo}</p>
<div className="buttonsSection">
<button className="transparent completeButton">
<CheckOutlined className="icon" />
</button>
<button
className="transparent deleteButton"
onClick={() => deleteTodo(index)}
>
<CloseOutlined className="icon" />
</button>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
export default App;
Adding the complete ✅ functionality
We first need to create a state value for the completed todos:
const [completedTodos, setCompletedTodos] = useState([]);
The complete function:
- Gets the pending todo as an argument.
- Adds the pending todo to
completedTodos
by using the spread operator [...]. - Deletes the todo from the
pendingTodos
array.
import React, { useState } from "react";
import { CloseOutlined, CheckOutlined } from "@ant-design/icons";
import "./App.css";
function App() {
const [typedInTodo, setTypedInTodo] = useState("");
const [pendingTodos, setPendingTodos] = useState([]);
const [completedTodos, setCompletedTodos] = useState([]);
function completeTodo(todoIndex) {
const pendingTask = pendingTodos[todoIndex];
setCompletedTodos([...completedTodos, pendingTask]);
deleteTodo(todoIndex);
}
function deleteTodo(todoIndex) {
const filteredTodos = pendingTodos.filter(
(_, index) => todoIndex !== index
);
setPendingTodos(filteredTodos);
}
function onKeyDown(e) {
if (e.key === "Enter" && typedInTodo.trim()) {
setPendingTodos([...pendingTodos, typedInTodo]);
setTypedInTodo("");
}
}
return (
<div className="app">
<h1>Todo</h1>
<input
type="text"
placeholder="Add todo..."
value={typedInTodo}
onChange={(event) => setTypedInTodo(event.target.value)}
onKeyDown={onKeyDown}
/>
<div className="sectionsContainer">
<div className="todoContainer">
<h2
className={
pendingTodos.length > 0
? "boldSectionTitle"
: "dimmedSectiontTitle"
}
>
Pending
</h2>
<div>
{pendingTodos.map((todo, index) => (
<div key={index} className="todoItem">
<p>{todo}</p>
<div className="buttonsSection">
<button
className="transparent completeButton"
onClick={() => completeTodo(index)}
>
<CheckOutlined className="icon" />
</button>
<button
className="transparent deleteButton"
onClick={() => deleteTodo(index)}
>
<CloseOutlined className="icon" />
</button>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
export default App;
Since the Completed section is exactly the same as the Pending one, let's copy paste the same code and just remove the complete button from it and update the section's heading:
import React, { useState } from "react";
import { CloseOutlined, CheckOutlined } from "@ant-design/icons";
import "./App.css";
function App() {
const [typedInTodo, setTypedInTodo] = useState("");
const [pendingTodos, setPendingTodos] = useState([]);
const [completedTodos, setCompletedTodos] = useState([]);
function completeTodo(todoIndex) {
const pendingTask = pendingTodos[todoIndex];
setCompletedTodos([...completedTodos, pendingTask]);
deleteTodo(todoIndex, "pending");
}
function deleteTodo(todoIndex) {
const filteredTodos = pendingTodos.filter(
(_, index) => todoIndex !== index
);
setPendingTodos(filteredTodos);
}
function onKeyDown(e) {
if (e.key === "Enter" && typedInTodo.trim()) {
setPendingTodos([...pendingTodos, typedInTodo]);
setTypedInTodo("");
}
}
return (
<div className="app">
<h1>Todo</h1>
<input
type="text"
placeholder="Add todo..."
value={typedInTodo}
onChange={(event) => setTypedInTodo(event.target.value)}
onKeyDown={onKeyDown}
/>
<div className="sectionsContainer">
<div className="todoContainer">
<h2
className={
pendingTodos.length > 0
? "boldSectionTitle"
: "dimmedSectiontTitle"
}
>
Pending
</h2>
<div>
{pendingTodos.map((todo, index) => (
<div key={index} className="todoItem">
<p>{todo}</p>
<div className="buttonsSection">
<button
className="transparent completeButton"
onClick={() => completeTodo(index)}
>
<CheckOutlined className="icon" />
</button>
<button
className="transparent deleteButton"
onClick={() => deleteTodo(index)}
>
<CloseOutlined className="icon" />
</button>
</div>
</div>
))}
</div>
</div>
<div className="todoContainer">
<h2
className={
completedTodos.length > 0
? "boldSectionTitle"
: "dimmedSectiontTitle"
}
>
Completed
</h2>
<div>
{completedTodos.map((todo, index) => (
<div key={index} className="todoItem">
<p>{todo}</p>
<div className="buttonsSection">
<button
className="transparent deleteButton"
onClick={() => deleteTodo(index)}
>
<CloseOutlined className="icon" />
</button>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
export default App;
Notice that the deleteTodo function only works with pending todos.
Making the delete ❌ functionality work in the Completed Section
To make our deleteTodo
function support both sections, we should provide it a second argument that tells it what the target section is.
Based on that, the deleteTodo
function can then know whether to delete a pending todo or a completed one.
After all the only things that change when deleting a completed or a pending todo are the target todo list and its state setter function:
import React, { useState } from "react";
import { CloseOutlined, CheckOutlined } from "@ant-design/icons";
import "./App.css";
function App() {
const [typedInTodo, setTypedInTodo] = useState("");
const [pendingTodos, setPendingTodos] = useState([]);
const [completedTodos, setCompletedTodos] = useState([]);
function completeTodo(todoIndex) {
const pendingTask = pendingTodos[todoIndex];
setCompletedTodos([...completedTodos, pendingTask]);
deleteTodo(todoIndex, "pending");
}
function deleteTodo(todoIndex, targetSection) {
const targetList =
targetSection === "pending" ? pendingTodos : completedTodos;
const setter =
targetSection === "pending" ? setPendingTodos : setCompletedTodos;
const filteredTodos = targetList.filter((_, index) => todoIndex !== index);
setter(filteredTodos);
}
function onKeyDown(e) {
if (e.key === "Enter" && typedInTodo.trim()) {
setPendingTodos([...pendingTodos, typedInTodo]);
setTypedInTodo("");
}
}
return (
<div className="app">
<h1>Todo</h1>
<input
type="text"
placeholder="Add todo..."
value={typedInTodo}
onChange={(event) => setTypedInTodo(event.target.value)}
onKeyDown={onKeyDown}
/>
<div className="sectionsContainer">
<div className="todoContainer">
<h2
className={
pendingTodos.length > 0
? "boldSectionTitle"
: "dimmedSectiontTitle"
}
>
Pending
</h2>
<div>
{pendingTodos.map((todo, index) => (
<div key={index} className="todoItem">
<p>{todo}</p>
<div className="buttonsSection">
<button
className="transparent completeButton"
onClick={() => deleteTodo(index, "pending")}
>
<CheckOutlined className="icon" />
</button>
<button
className="transparent deleteButton"
onClick={() => deleteTodo(index, "completed")}
>
<CloseOutlined className="icon" />
</button>
</div>
</div>
))}
</div>
</div>
<div className="todoContainer">
<h2
className={
completedTodos.length > 0
? "boldSectionTitle"
: "dimmedSectiontTitle"
}
>
Completed
</h2>
<div>
{completedTodos.map((todo, index) => (
<div key={index} className="todoItem">
<p>{todo}</p>
<div className="buttonsSection">
<button
className="transparent deleteButton"
onClick={() => deleteTodo(index)}
>
<CloseOutlined className="icon" />
</button>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
export default App;
The todo app is working now, but we should try to make our code more readable by removing some redundancy.
Refactoring
If you look at our rendered sections code you can notice that it is just one list of todos in a section that we duplicated to create another section. So why don’t we just create that list as a single reusable component and render our sections lists Conditionally based on the necessary props we pass to that component.
What are these props that our list component needs in order to render the sections we created in the previous approach?
-
sectionType: either
"Pending"
or"Completed"
- todoList: the list of todos to render (either completedTodos or pendingTodos).
-
deleteTodo
andcompleteTodo
functions.
Let’s refactor our code following these steps :
- Create a variable named
sectionTypeTitle
to store the values of the first prop and avoid potential typos. - Create our component, call it
TodoList
and pass to it the props. - Show the appropriate section header based on
sectionType
. - Only render the complete button if the
sectionType
is"pending"
. - Map through the passed
todoList
and render the todos.
import React, { useState } from "react";
import { CloseOutlined, CheckOutlined } from "@ant-design/icons";
import "./App.css";
const sectionTypeTitle = {
completed: "Completed",
pending: "Pending",
};
function App() {
const [typedInTodo, setTypedInTodo] = useState("");
const [pendingTodos, setPendingTodos] = useState([]);
const [completedTodos, setcompletedTodos] = useState([]);
function completeTodo(todoIndex) {
const pendingTask = pendingTodos[todoIndex];
setcompletedTodos([...completedTodos, pendingTask]);
deleteTodo(todoIndex, "pending");
}
function deleteTodo(todoIndex, todoSection) {
const targetList =
todoSection === "pending" ? pendingTodos : completedTodos;
const setter =
targetList === pendingTodos ? setPendingTodos : setcompletedTodos;
const filteredTodos = targetList.filter((_, index) => todoIndex !== index);
setter(filteredTodos);
}
function onKeyDown(e) {
if (e.key === "Enter" && typedInTodo.trim()) {
setPendingTodos([...pendingTodos, typedInTodo]);
setTypedInTodo("");
}
}
return (
<div className="app">
<h1 className="title">Todo</h1>
<input
type="text"
placeholder="Add todo..."
value={typedInTodo}
onChange={(event) => setTypedInTodo(event.target.value)}
onKeyDown={onKeyDown}
/>
<div className="sectionsContainer">
<TodoList
sectionTitle="pending"
completeTodo={completeTodo}
deleteTodo={deleteTodo}
todoList={pendingTodos}
/>
<TodoList
sectionTitle="completed"
todoList={completedTodos}
deleteTodo={deleteTodo}
/>
</div>
</div>
);
}
export default App;
function TodoList({ sectionTitle, completeTodo, deleteTodo, todoList }) {
return (
<div className="todoContainer">
<h2
className={
todoList.length > 0 ? "boldSectionTitle" : "dimmedSectiontTitle"
}
>
{sectionTypeTitle[sectionTitle]}
</h2>
<div>
{todoList.map((todo, index) => (
<div className="todoItem" key={index}>
<span>{todo}</span>
<div className="buttonsSection">
{sectionTitle === "pending" && (
<button
className="transparent completeButton"
onClick={() => completeTodo(index)}
>
<CheckOutlined className="icon" />
</button>
)}
<button
className="transparent deleteButton"
onClick={() => deleteTodo(index, sectionTitle)}
>
<CloseOutlined className="icon" />
</button>
</div>
</div>
))}
</div>
</div>
);
}
Adding validation to our code
One last thing we can add to our components is props validation using prop-types.
Our passed in props need to be of certain types and we need to enforce that in our code to avoid catching bugs.
First, we import PropTypes
from 'prop-types'
at the top of our app code.
import PropTypes from 'prop-types';
Then use the validators that come with it to validate the data our TodoList
component receives.
TodoList.propTypes = {
sectionTitle: PropTypes.oneOf(["pending", "completed"]).isRequired,
completeTodo: PropTypes.func,
deleteTodo: PropTypes.func.isRequired,
todoList: PropTypes.arrayOf(PropTypes.string),
};
Let’s add it to our final app code :
import React, { useState } from "react";
import PropTypes from "prop-types";
import { CloseOutlined, CheckOutlined } from "@ant-design/icons";
import "./App.css";
const sectionTypeTitle = {
completed: "Completed",
pending: "Pending",
};
function App() {
const [typedInTodo, setTypedInTodo] = useState("");
const [pendingTodos, setPendingTodos] = useState([]);
const [completedTodos, setcompletedTodos] = useState([]);
function completeTodo(todoIndex) {
const pendingTask = pendingTodos[todoIndex];
setcompletedTodos([...completedTodos, pendingTask]);
deleteTodo(todoIndex, "pending");
}
function deleteTodo(todoIndex, todoSection) {
const targetList =
todoSection === "pending" ? pendingTodos : completedTodos;
const setter =
targetList === pendingTodos ? setPendingTodos : setcompletedTodos;
const filteredTodos = targetList.filter((_, index) => todoIndex !== index);
setter(filteredTodos);
}
function onKeyDown(e) {
if (e.key === "Enter" && typedInTodo.trim()) {
setPendingTodos([...pendingTodos, typedInTodo]);
setTypedInTodo("");
}
}
return (
<div className="app">
<h1 className="title">Todo</h1>
<input
type="text"
placeholder="Add todo..."
value={typedInTodo}
onChange={(event) => setTypedInTodo(event.target.value)}
onKeyDown={onKeyDown}
/>
<div className="sectionsContainer">
<TodoList
sectionTitle="pending"
completeTodo={completeTodo}
deleteTodo={deleteTodo}
todoList={pendingTodos}
/>
<TodoList
sectionTitle="completed"
todoList={completedTodos}
deleteTodo={deleteTodo}
/>
</div>
</div>
);
}
export default App;
function TodoList({ sectionTitle, completeTodo, deleteTodo, todoList }) {
return (
<div className="todoContainer">
<h2
className={
todoList.length > 0 ? "boldSectionTitle" : "dimmedSectiontTitle"
}
>
{sectionTypeTitle[sectionTitle]}
</h2>
<div>
{todoList.map((todo, index) => (
<div className="todoItem" key={index}>
<span>{todo}</span>
<div className="buttonsSection">
{sectionTitle === "pending" && (
<button
className="transparent completeButton"
onClick={() => completeTodo(index)}
>
<CheckOutlined className="icon" />
</button>
)}
<button
className="transparent deleteButton"
onClick={() => deleteTodo(index, sectionTitle)}
>
<CloseOutlined className="icon" />
</button>
</div>
</div>
))}
</div>
</div>
);
}
TodoList.propTypes = {
sectionTitle: PropTypes.oneOf(["pending", "completed"]).isRequired,
completeTodo: PropTypes.func,
deleteTodo: PropTypes.func.isRequired,
todoList: PropTypes.arrayOf(PropTypes.string),
};
Conclusion
I publish articles monthly and I'm currently looking for my first Frontend Developer job in either Europe or Canada.
Stay tuned by following me on Twitter (@amir_ghezala) or checking my portfolio.
Top comments (0)