Build a todo app in React, using hooks and functional components

Let's build a todo app in React, step by steps, with no steps ommitted

By: Ajdin Imsirovic 25 January 2022

< Back to TOC

We’ve started the book with some practical work, all up to the couple of the previous chapters, where we’ve “filled in some blanks” with some theoretical background.

Now we’ll build a nice project that we can call our own.

The project is the simplest possible todo app in React. We’ll make it as simple as they come.

However, through the building of this app, we’ll practice all the things we’ve learned so far, which will have a double effect:

  • first, we’ll complete a simple, but working app in React
  • second, we’ll reinforce the things we’ve learned in previous chapters

Note that throughout this chapter, we’ll be using Codepen to build our app. In the next chapter, we’ll build it again, only locally.

Let’s get started!

Table of contents

  1. Add the app component with basic static structure
  2. Get the initial state dynamically using the useState React hook
  3. Convert the HTML form element to a separate functional component
  4. Extract the state data to a separate component
  5. Handle submit events on the form
  6. Validate that the form input isn’t blank
  7. Get the value of user input and store it in the TodoInput component’s state variable
  8. Update the todos state with user-provided input
  9. Add the done button on each of the todo items
  10. Add the styling on the todos that are done
  11. Add the delete todo functionality
  12. Make the input text reset after a newly added todo
  13. Make the buttons not lose opacity on the Done toggle
  14. Make the Done button’s text value update based on the state of the todo item

Add the App component with basic static structure

In the very beginning of the development of our todo app, it will consist of two parts:

  1. The list of existing todos
  2. The input to add new todos

To begin with, let’s just hardcode these, as follows:

const App = () => {
  return (
    <div className="app">
      <div className="todo-app">
        <ul className="current-todos">
          <li>Go shopping</li>
          <li>Wash dishes</li>
        </ul>
        <form>
          <input className="add-todo" type="text" />
        </form>
      </div>
    </div>
  );
};

ReactDOM.render(<App />, document.getElementById("root"));

This first version of the todo app is saved as a Codepen titled React todo, pt 1.

Our app is as simple as can be at this point: The basic static structure of our todo app

Get the initial state dynamically using the useState React hook

We start by destructuring the useState hook from React:

const { useState } = React;

Using useState we set the state in the todos const, and the method to update it as setTodos:

const [todos, setTodos] = useState([
  { text: "Go shopping" },
  { text: "Wash dishes" }
]);

Let’s quickly revise how the useState and the array destructuring works in the code above.

Basically, the above code is the same as this:

const data = [
  { text: "Go shoping" },
  { text: "Wash dishes" }
];
const initialTodosState = useState(data)
const todos = initialTodosState[0];
const setTodos = initialTodosState[1];

Writing it using array destructuring is just a nicer way to deal with things here. That’s all there is to it.

Next, we loop over the data stored in the todos const in the return statement:

{todos.map((todo,index) => (
  <Todo key="index" todo={todo} />
))}

And we add a new Todo component to make everything work:

const Todo = (props) => {
  return (
    <li>
      {props.todo.text}
    </li>
  )
}

After the above updates, our app still looks exactly the same as before.

However, now we can add as many li elements as we’d like, dynamically, by updating the array we pass to the useState() method call. For example:

const [todos, setTodos] = useState([
  { text: "Go shopping" },
  { text: "Wash dishes" },
  { text: "Study for the exam" }
]);

The changes to the passed-in array now appear in the browser.

Dynamically rendering li tags using the useState method

Wonderful! We’re now getting the state dynamically, using the useState React hook.

This improvement is saved as a Codepen titled React todo, pt 2

Next, let’s add the functionality to add state.

Convert the HTML form element to a separate functional component

Let’s now update our static form input, which currently is just some plain HTML:

<form>
  <input class="add-todo" type="text" />
</form>

Above the App component, let’s add a new functional component:

const TodoInput = () => {
  return (
    <form>
      <input disabled className="add-todo" type="text" />
    </form>
  )
}

And now let’s use the TodoInput component inside the App component:

const App = () => {
  // ... useState section skipped for brevity ...
    return (
    <div className="app">
      <div class="todo-app">
        <ul class="current-todos">
          {todos.map((todo,index) => (
            <Todo key="index" todo={todo} />
          ))}
        </ul>
        <TodoInput />
      </div>
    </div>
  );
};

Note the addition of the disabled keyword here:

<input disabled className="add-todo" type="text" />

This was done so that our app plays nice at this point of its development.

The current update to our app is saved as a Codepen titled React todo, pt 3

Extract the state data to a separate component

While we’re in the part 3 of our React todo app development, there’s another little thing we can do, and that is: we’ll extract the array of objects holding the initial state. We’ll save this extracted array in a variable aptly named data.

Here’s the updated App component where all of this is happening:

const App = () => {
  const data = [
    { text: "Go shopping" },
    { text: "Wash dishes" },
    { text: "Study for the exam" }
  ]

  const [todos, setTodos] = useState(data);

  return (
    <div className="app">
      <div className="todo-app">
        <ul className="current-todos">
          {todos.map((todo,index) => (
            <Todo key={index} todo={todo} />
          ))}
        </ul>
        <TodoInput />
      </div>
    </div>
  );
};

The above update makes for a bit more readable (understandable) code, with a cleaned up line where we’re using the useState:

const [todos, setTodos] = useState(data);

Next, we’ll handle submit events on our app’s form.

Handle submit events on the form

To do this, we’ll need to update the TodoInput component. Specifically, we’ll bind the onSubmit event to an event-handler function.

const TodoInput = () => {
  return (
    <form onSubmit={handleSubmit} >
      <input class="add-todo" type="text" />
    </form>
  )
}

Obviously, we now need to define the handleSubmit function in our TodoInput component:

const TodoInput = () => {

  const handleSubmit = evt => {
    evt.preventDefault();
    alert('Adding new todo');
  }

  return (
    <form onSubmit={handleSubmit} >
      <input class="add-todo" type="text" />
    </form>
  )
}

For now, we’re just alerting the string Adding new todo. That’s how we’re currently “handling” the submit event.

To improve our event handling, we need to do two things:

  1. Check that the value that’s passed in is not an empty string. If it is an empty string, we’ll warn the user about a non-valid entry.
  2. If the passed-in value is a regular string, we’ll need to add it to the array of todos.

    Validate that the form input isn’t blank

Let’s tackle the empty string validation first:

const TodoInput = () => {
  const [ userInput, setUserInput ] = useState("");

  const handleSubmit = evt => {
    evt.preventDefault();
    if (!userInput) alert("You need to type something!")
  }

  return (
    <form onSubmit={handleSubmit} >
      <input className="add-todo" type="text" />
    </form>
  )
}

We’re again using the useState hook - this time to work with state in the TodoInput component.

For now, we’re using the useState hook to populate the value of userInput so as to be able to validate if it’s truthy or falsy.

If it’s falsy, we’re alerting the user with a warning message, which takes care of our very basic validation.

Get the value of user input and store it in the TodoInput component’s state variable

Since we’ve added the ability to work with state, we can now get the state out of the input form when the user types into it.

Here’s a very basic way to get the value that the user inputs:

const TodoInput = () => {
  const [ userInput, setUserInput ] = useState("");

  const handleSubmit = evt => {
    evt.preventDefault();
    if (!userInput) {
      alert("You need to type something!");
      return;
    }
    alert(userInput)
  }

  return (
    <form onSubmit={handleSubmit} >
      <input
        className="add-todo"
        type="text"
        value={userInput}
        onChange={ evt => setUserInput(evt.target.value) }
      />
    </form>
  )
}

What we’re doing above is: we’re alerting the userInput value, but only after we’ve checked that it isn’t falsy - if it is falsy, we alert the warning and return from the function immediately.

Inside the input, we’re adding an onChange JSX attribute, and we’re passing it a callback function which will get called when the value of the input changes.

We’ve setting the user-provided value of the input element to the userInput variable.

So, we’re getting the onChange event’s evt object in the callback, and we’re using the target.value on the evt object to set the value on userInput variable - using the setUserInput (which we’ve previously destructured from the useState() method call).

This improved version is saved as a Codepen titled React todo, pt 4.

Update the todos state with user-provided input

We’re now ready to update the state of our todos variable, which we’ve previously destructured from the useState array.

Coming from the JavaScript land, we might think of using todos.push() and then just push the string that the user typed into the form, but this won’t work as we’d like, because React wouldn’t get notified of the update to the todos array - thus making this kind of update sort of useless.

Instead, we’ll use the setTodos() function - this is the correct way to update our code, because we’re using the useState() hook.

The syntax for this update works like this: instead of todos.push( // whatever goes here ), we’ll do this:

setTodos([...todos, { userInput }]);

To understand how the above syntax works, we need to step back and re-visit some code in the JavaScript language.

Consider the following code:

let arr = [1,2,3];
arr = [...arr, 4]; // --> returns: [1,2,3,4]

Alright, so this is pretty straightforward.

However, our example’s syntax looks like this:

setTodos([...todos, { userInput }]);

What’s with this { userInput } syntax?

It’s just a way to add another object.

For this, we’ll have to look at another JS snippet:

let todos = [
  { text: "a" },
  { text: "b" }
]
const userInput = "c";
todos = [...todos, { userInput }];
todos; // --> [{ text: 'a'}, { text: 'b' }, { userInput: 'c' }]

Note: The third object inside the above example’s todos array is:

{ userInput: 'c' }

This will be very important a bit later, so keep it in mind.

Now that we understand the setTodos([...todos, { userInput }]) line, we’re a step closer to understanding the updates we’ll make in this section.

Let’s inspect the entire app as it was in version 4, but this time we’ll also comment the places where we’ll add updates and the logic behind it.

Here’s the v4 of our app, with relevant comments where we’ll be adding changes:

const { useState } = React;

const Todo = (props) => {
  return (
    <li>
      {props.todo.text}
    </li>
  )
}

const TodoInput = () => { // we need access to todos and setTodos state here (1)
  const [ userInput, setUserInput ] = useState("");
  // we'll log out the todos here, as a sanity check (2)
  const handleSubmit = evt => {
    evt.preventDefault();
    if (!userInput) {
      alert("You need to type something!");
      return;
    }
    alert(userInput); // we'll replace it with console.log - it's better UX (3)
    // we'll assign userInput to text (4)
    // we'll call setTodos() and pass in the ...todos, and { text } (5)
  }

  return (
    <form onSubmit={handleSubmit} >
      <input
        className="add-todo"
        type="text"
        value={userInput}
        onChange={ evt => setUserInput(evt.target.value) }
      />
    </form>
  )
}

const App = () => {
  const data = [
    { text: "Go shopping" },
    { text: "Wash dishes" },
    { text: "Study for the exam" }
  ]

  const [todos, setTodos] = useState(data);

  return (
    <div className="app">
      <div className="todo-app">
        <ul className="current-todos">
          {todos.map((todo,index) => (
            <Todo key={index} todo={todo} />
          ))}
        </ul>
        <TodoInput />{ /* we'll pass state props to this component (6) */ }
      </div>
    </div>
  );
};

ReactDOM.render(<App />, document.getElementById("root"));

There you have it, our work is now cut out for us. Here’s an overview of the updates that we need to make:

  1. We need access to todos and setTodos state here - we can simply pass it and destructure the props object on the spot, using this syntax: { todos, setTodos }.
  2. We’ll log out todos so that we can inspect what is going on in our console. Alternatively, we can use React devtools, but the console will suffice for our modest needs.
  3. The alert(userInput); line is really annoying, so let’s move this output to the console by replacing this line with console.log(userInput).
  4. Here we’ll need to assign whatever is inside userInput to text, so that we can…
  5. Call setTodos() and pass it the updated array of [...todos,{ text }]
  6. The <TodoInput /> component must receive props for todos and setTodos, so we’ll add it the necessary attributes.

After we’ve planned our updates and described the reasoning behind it, we can code a new version of our app. Here’s the full code:

const { useState } = React;

const Todo = (props) => {
  return (
    <li>
      {props.todo.text}
    </li>
  )
}

const TodoInput = ({setTodos, todos}) => {
  const [ userInput, setUserInput ] = useState("");
  console.log(todos);
  const handleSubmit = evt => {
    evt.preventDefault();
    if (!userInput) {
      alert("You need to type something!");
      return;
    }
    console.log(userInput);
    let text = userInput;
    setTodos([...todos, { text }]);
  }

  return (
    <form onSubmit={handleSubmit} >
      <input
        className="add-todo"
        type="text"
        value={userInput}
        onChange={ evt => setUserInput(evt.target.value) }
      />
    </form>
  )
}

const App = () => {
  const data = [
    { text: "Go shopping" },
    { text: "Wash dishes" },
    { text: "Study for the exam" }
  ]

  const [todos, setTodos] = useState(data);

  return (
    <div className="app">
      <div className="todo-app">
        <ul className="current-todos">
          {todos.map((todo,index) => (
            <Todo key={index} todo={todo} />
          ))}
        </ul>
        <TodoInput setTodos={setTodos} todos={todos} />
      </div>
    </div>
  );
};

ReactDOM.render(<App />, document.getElementById("root"));

This update allows us to have the user type into the input field and have a new todo appear in the list of todos.

This improved version is saved as a Codepen titled React todo, pt 5.

Next, we’ll add the done button on each of the todo items.

Add the done button on each of the todo items

Let’s start off with a simple update to the Todo component:

const Todo = (props) => {
  return (
    <li>
      {props.todo.text}
      <button onClick={() => console.log('clicked') } >
        Done
      </button>
    </li>
  )
}

We’ve added a button element with the onClick Synthetic event, which simply logs out clicked whenever any of the li element’s buttons is pressed.

Let’s now extract the above anonymous callback function into a separate named function.

We’ll name it markTodoDone:

const Todo = (props) => {

  const markTodoDone = () => {
    console.log('clicked');
  }

  return (
    <li>
      {props.todo.text}
      <button onClick={markTodoDone(index)} >
        Done
      </button>
    </li>
  )
}

Everything still works the same.

Let’s now pass to the markTodoDone the index of the actual li whose Done button we’re clicking. In order to do that, we actually need to “go up” a level - we’re no longer “looking” at our app from the viewpoint of a single todo, but rather from the point of the parent component. The parent component for the Todo component is the App component, and thus we need to update our app component as follows:

const App = () => {
  const data = [
    // ... code skipped for brevity ...
  ]

  const [todos, setTodos] = useState(data);

  const markTodoDone = index => {
    console.log(index);
  }

  return (
    <div className="app">
      <div className="todo-app">
        <ul className="current-todos">
          {todos.map((todo,index) => (
            <Todo key={index} index={index} todo={todo} markTodoDone={markTodoDone} />
          ))}
        </ul>
        <TodoInput setTodos={setTodos} todos={todos} />
      </div>
    </div>
  );
};

This change is that basically, we’re defining the markTodoDone() function in App, rather than in the Todo component.

Additionally, we need to pass the index prop to the Todo component, like this:

const Todo = (props) => {
  console.table(props);
  return (
    <li>
      {props.todo.text}
      <button onClick={() => props.markTodoDone(props.index)} >
        Done
      </button>
    </li>
  )
}

In the above update to the Todo component, we see that the props object has the todo object as its property, and the todo object has the text propery. This is what is being set as the text node of the li element.

Furthermore, the onClick syntethic event uses an anonymous callback, which gets no parameters, but does call the props.markTodoDone() from the parent App component. The props.markTodoDone() accepts the props.index parameter.

Obviously, in order to use the index, we need to pass it inside the App component, and thus we have the following line of code:

<Todo key={index} index={index} todo={todo} markTodoDone={markTodoDone} />

After all these updates, when a user clicks on any of the Done buttons, the index of that li element gets console logged.

Our todo app is now titled React todo, pt 6 on Codepen.

Add the styling on the todos that are done

Next, we’ll update the style on each todo so that it has a lower opacity.

The aim is to update each of our todo li elements’ styles when their Done button is clicked, as follows:

style="opacity: 0.4"

To achieve this, we’ll use the following syntax on our Todo component:

<li style={{ opacity: props.todo.isDone ? "0.4" : "1" }}>
 <!-- ... code skipped for brevity ... -->
</li>

Basically, what we’re doing above is: we’re checking if the isDone property on each of the todo objects in our state is set to true or false. If the isDone property is true, we add the value of 0.4 on the opacity style, otherwise we set the value of opacity to 1 (which is the browser’s default style).

However, to make this work, we need to update our initial todos state, stored in the data variable in the App component:

const data = [
  { text: "Go shopping", isDone: false },
  { text: "Wash dishes", isDone: false },
  { text: "Study for the exam", isDone: false }
]

Now that we’ve added another property to the data model, we need to update our markTodoDone method, as follows:

const markTodoDone = index => {
  console.log(index);
  const updatedTodos = [...todos];
  updatedTodos[index].isDone = !updatedTodos[index].isDone;
  setTodos(updatedTodos);
}

After this update, we can toggle a todo being in the “Done” state, which is confirmed through a change in opacity on a clicked todo item.

This update is saved on Codepen as React todo, pt 7.

Add the delete todo functionality

Next, we’ll add a way to delete todos.

Before we do that, and based on the things we’ve learned so far, here’s how that would work:

  1. First, inside the App component’s return statement, in the section where we’re mapping over the todos data, inside the looped-over Todo component, we’ll add another prop with which we’ll pass the data to the actual Todo component’s template. Effectively, we’re adding another entry to the props object that the Todo component will receive from its parent component (App).
  2. On the side of the functional Todo component, we’re still just receiving a single object, props, but now we know that this props object has another property - the property which will allow us to remove a todo. We’ll name this property deleteTodo. Thus, in the return statement of the Todo functional component, we’ll need to add another button, with the text “delete”, and we’ll use the onClick synthetic event to call an anonymous function which receives no arguments but does call the deleteTodo function, and accepts a single parameter: the index of the todo to be deleted.
  3. Back inside the App component, we need to define the deleteTodo() method, which, just like the markTodoDone(), will accept the index, build a new array from the todos array, and delete the todo with a given index from itself. Then we’ll need to setTodos with the updated todos array value.

After this in-depth explanation, let’s now reiterate, with an abbreviated explanation of the updates:

  1. In App, in Todo, pass a prop named deleteTodo, and give it the value of deleteTodo.
  2. In Todo component’s return, add a button with onClick which calls the deleteTodo(index).
  3. In App, define a new function, deleteTodo, pass it the index, update the existing todos to a new one, delete the index position in the todos array, and return the updated array using the setTodos useState’s update method.

That’s all there is to it!

Here’s the relevant part of the update in App component’s return statement:

{todos.map((todo,index) => (
  <Todo
    key={index}
    index={index}
    todo={todo}
    markTodoDone={markTodoDone}
    deleteTodo={deleteTodo}
  />
))}

Here’s the relevant part of the update in Todo component’s return statement:

return (
  ...
    <button onClick={() => props.deleteTodo(props.index)} >
      Delete
    </button>
  </li>
)

And here’s the new deleteTodo in App:

const deleteTodo = index => {
  console.log(index);
  const updatedTodos = [...todos];
  updatedTodos.splice(index, 1);
  setTodos(updatedTodos);
}

This update is saved on Codepen as React todo, pt 8.

This completes the basic functionality of our very simple todo app in React.

It’s always possible to make things even better, and in the next section, we’ll improve a couple of minor tweaks:

  1. Make the text in the input disappear after the addition of the new todo (currently, it lingers inside the input)
  2. Make the “Done” and “Delete” buttons not lose opacity on the Done toggle.
  3. Make the “Done” button’s text toggle between “Done” and “Todo”

Make the input text reset after a newly added todo

This one is really easy to fix. At the very bottom of the handleSubmit method of the TodoInput component, we’ll just add one more line that reads:

setUserInput("");

Since we’re tracking the user input’s state using the useState hook in the TodoInput method, that’s all that we need to do to make that pesky piece of text disappear.

Make the buttons not lose opacity on the Done toggle

This update is also pretty easy; all we need to do is reorganize where the styles appear in the return statement of the Todo component. Specifically, this means that we need to move the styles from the li into a new span element, which now wraps the {props.todo.text} string:

<span style={{ opacity: props.todo.isDone ? "0.4" : "1" }}>
  {props.todo.text}
</span>

Make the Done button’s text value update based on the state of the todo item

To do this, we’ll need to remind ourselves of how fragments work in React.

So, our plan is to use a ternary statement which checks whether the value of the props.todo.isDone is true or false, and use one or the other fragment accordingly.

Actually, it’s even simpler: since all we want to do is show either the words “Done” or “To do”, we can fit everything inside a neat little ternary statement:

{ props.todo.isDone ? "Done" : "Todo"}

That’s it, we’ve completed our three simple tweaks.

They are saved in a new version of our app titled React todo, pt 9.

This completes our chapter on building a Todo app in React on Codepen.

In the next chapter, we’ll repeat this process, only locally.

Additionally, we’ll use the things we’ve learned in this chapter and the previous ones, to have a more structured approach to building the todo app, with the aim to hopefully reinforce learning.

< Prev lesson Next lesson >

Feel free to check out my work here: