How to build a Todo List App with JavaScript

Ah yes, the todo list. Probably the most popular thing to build when learning a new programming language. Some people claim building a todo list app is a boring activity since so many exist already.

However, it remains a useful exercise in building something relatable especially if you’re new to JavaScript, and programming in general. And many of the concepts learned in the process of building a todo list app can be applied to other projects you might want to take on.

So, if you’re not turned off by the idea of building yet another todo list app, and you’re relatively new to JavaScript and Front-End development, this tutorial is for you.

Here’s a live demo of the completed project.

Prerequisites

This tutorial assumes a basic knowledge of JavaScript. In essence, you must know what variables, arrays, functions and objects are, but you do not need to have previous experience with building JavaScript applications.

Getting started

The todo list app we’ll be building will be pretty basic. A user can add a task, mark a task as completed and delete an already added task. I’ll explain how to build each feature, but you must follow through by typing the code and running it on your end to get the most out of the tutorial.

I recommend using JSFiddle while working through this tutorial, but feel free to use other code playgrounds or your local text editor if you prefer.

Without further ado, grab the markup and styles for this project on JSFiddle. If you’re using JSFiddle, hit the the Fork button to create a new fiddle of your own.

Add a todo

The first thing we need to do is set up an array to hold our todo list items. Each todo will be an object with three properties: text which holds whatever the user types into the text input, checked which helps us know if a task has been marked completed or not, and id, a unique identifier for the todo item.

Once the user adds a task, we’ll push a new todo object into the array and render the text on the screen. When the user completes a todo by checking it off, we’ll toggle the checked property to true, and when the user deletes a todo, we’ll locate the todo item in the array using its id and remove it.

Let’s start by adding a todo item to our list. To do so, we need to listen for the submit event on .js-form, and then invoke a new addTodo() function when the form is submitted.

Update the JavaScript pane on JSFiddle to look like this:

let todoItems = [];

function addTodo(text) {
  const todo = {
    text,
    checked: false,
    id: Date.now(),
  };

  todoItems.push(todo);
  console.log(todoItems);
}

const form = document.querySelector('.js-form');
form.addEventListener('submit', event => {
  event.preventDefault();
  const input = document.querySelector('.js-todo-input');

  const text = input.value.trim();
  if (text !== '') {
    addTodo(text);
    input.value = '';
    input.focus();
  }
});

Normally, when a form is submitted, the browser will attempt to submit the form to a server which will cause a page refresh. Since we’re not interested in doing that, we stop the default behaviour using event.preventDefault().

Next, we select the text input and trim its value to remove whitespace from the beginning and end of the string, and save it in a new variable called text.

If the text variable is not equal to an empty string, we pass the text to the addTodo() function which has been defined above the event listener. Within the function, we create a new object for the task and add the properties I mentioned earlier.

The text property is set to the text from the input, checked is initialised to false, and id is initialised to the number of milliseconds elasped since January 1, 1970. This id will be unique for each todo item, unless you can add more than one task in a millisecond, which I don’t think is possible.

Finally, the task is pushed to the todoItems array, and the array is logged to the console. The value of the text input is also cleared by setting it to an empty string, and is also focused so that the user can add multiple items to the list without having to focus the input over and over again.

Add a few todo items and view the todoItems array in the console. You will see each todo item represented by an object in the array.

Console showing array of todo objects

Render the todo items

Once we add a todo to the todoItems array, we want the page to be updated and the item rendered on the screen. We can do this pretty easily by appending a new li element for each item to .js-todo-list.

To achieve this, replace the console.log() statement in addTodo() as follows:

function addTodo(text) {
  const todo = {
    text,
    checked: false,
    id: Date.now(),
  };

  todoItems.push(todo);

  const list = document.querySelector('.js-todo-list');
  list.insertAdjacentHTML('beforeend', `
    <li class="todo-item" data-key="${todo.id}">
      <input id="${todo.id}" type="checkbox"/>
      <label for="${todo.id}" class="tick js-tick"></label>
      <span>${todo.text}</span>
      <button class="delete-todo js-delete-todo">
        <svg><use href="#delete-icon"></use></svg>
      </button>
    </li>
  `);
}

The insertAdjacentHTML() method allows us to add some HTML to the DOM using an already present element as a reference point. In this case, we’re using the .js-todo-list as a reference and appending a new li element for each todo item before its closing tag.

Notice that on each todo item, we have a data-key attribute that is set to the item’s id. This is an important step, as it allows us to locate a todo item easily in the DOM. You’ll see how we’ll use it when we get to the section on checking off and deleting tasks.

Try it out. When a new task is entered by the user, it will be rendered on the page.

Image of todo items added to the list

Mark a task as ‘done’

The next feature to implement is the ability to mark a task as completed. To do so, we need to listen for the click event on the checkbox and then toggle the checked property on the todo item as needed.

Add the following event listener at the bottom of the JS pane:

const list = document.querySelector('.js-todo-list');
list.addEventListener('click', event => {
  if (event.target.classList.contains('js-tick')) {
    const itemKey = event.target.parentElement.dataset.key;
    toggleDone(itemKey);
  }
});

Instead of listening for clicks on individual checkbox elements, we are listening for clicks on .js-todo-list. When a click event occurs on the list, we check if the element that was clicked is the checkbox. If so, we extract the value of data-key on the checkbox’s parent element, and pass it off to a new toggleDone() method which will be created below the addTodo() function.

function toggleDone(key) {
  const index = todoItems.findIndex(item => item.id === Number(key));
  todoItems[index].checked = !todoItems[index].checked;

  const item = document.querySelector(`[data-key='${key}']`);
  if (todoItems[index].checked) {
    item.classList.add('done');
  } else {
    item.classList.remove('done');
  }
}

This function receives the key of the list item that was checked or unchecked and finds the corresponding entry in the todoItems array using the findIndex method. Then we toggle the value of the checked property to the opposite value. Finally, the done class is added or removed from the item depending on its checked status. This class has the effect of striking out the text and showing a checkmark in the checkboxes of completed items.

Todo items can be checked off

Delete todo items

Similar to the way we implemented the last feature, we’ll listen for clicks on the .js-delete-todo element, then grab the key of the parent and pass it off to a new deleteTodo function which will remove the corresponding entry in todoItems and remove the todo item from the DOM.

First, detect when the delete button is clicked:

const list = document.querySelector('.js-todo-list');
list.addEventListener('click', event => {
  if (event.target.classList.contains('js-tick')) {
    const itemKey = event.target.parentElement.dataset.key;
    toggleDone(itemKey);
  }

  // add this `if` block
  if (event.target.classList.contains('js-delete-todo')) {
    const itemKey = event.target.parentElement.dataset.key;
    deleteTodo(itemKey);
  }
});

Then create the deleteTodo() function below toggleDone() as follows:

function deleteTodo(key) {
  todoItems = todoItems.filter(item => item.id !== Number(key));
  const item = document.querySelector(`[data-key='${key}']`);
  item.remove();
}

Now, you should be able to delete tasks by hitting the delete button.

Add an empty state prompt

An empty state occurs when there is no data to show in the app. For example, when the user hasn’t added a todo yet (first use) or when the user has cleared the list. It is important to account for this state when designing an application.

Many apps use the empty state to show a prompt that tell the user what to do. Here is a real-world example of what a good empty state prompt looks like:

Hangouts empty state

And here’s an example of a not so great empty state design:

Unsplash empty state

For this exercise, once there are no tasks to display, we’ll add a prompt that encourages the user to add a new task. This feature can be implemented with just some HTML and CSS. We will take advantage of the :empty selector in CSS to display the prompt conditionally only if no items exist in the list.

Add the markup for the empty state prompt as shown below:

<main>
  <div class="container">
    <h1 class="app-title">todos</h1>
    <ul class="todo-list js-todo-list"></ul>
    <!-- add the empty state here -->
    <div class="empty-state">
      <svg class="checklist-icon"><use href="#checklist-icon"></use></svg>
      <h2 class="empty-state__title">Add your first todo</h2>
      <p class="empty-state__description">What do you want to get done today?</p>
    </div>
    <!-- end -->
    <form class="todo-form js-form">
      <input autofocus type="text" aria-label="Enter a new todo item" placeholder="E.g. Build a web app" class="js-todo-input">
    </form>
  </div>
</main>

<!-- rest of the code -->

Then add some styles for the empty state in your CSS:

/* Add this below all the other styles */

.empty-state {
  flex-direction: column;
  align-items: center;
  justify-content: center;
  display: flex;
}

.checklist-icon {
  margin-bottom: 20px;
}

.empty-state__title, .empty-state__description {
  margin-bottom: 20px;
}

While this looks just fine, the problem is the message persists even when a task has been added to the list. The intended behaviour is for the prompt to disappear once a todo has been added and only reappear when there are no more tasks to display.

Empty state prompt is not hidden when a task is added

This bit of CSS will give us what we want:

/* Change the `display: flex` to `display: none` */

.empty-state {
  flex-direction: column;
  align-items: center;
  justify-content: center;
  display: none;
}

/* Add this below the other styles */
.todo-list:empty {
  display: none;
}

.todo-list:empty + .empty-state {
  display: flex;
}

.empty-state is hidden from view by default with display: none and only comes into view when .todo-list is empty. We’re using the :empty selector to detect when .todo-list is empty, and the sibling selector (+) to target .empty-state and apply display: flex to it only when .todo-list is empty.

Now, we have a fully functional todo list application, and we didn’t have to write a lot of code to achieve this.

The empty state is hidden once a new todo item is added to the list

A subtle bug

One issue I encountered while working on this tutorial is that the empty state wouldn’t return into view when all existing tasks are deleted.

Empty state bug

The problem is that some whitespace persists in the .todo-list element even though all the child li elements has been removed, so it’s not considered to be an empty element. To fix this problem, we need to clear any whitespace from the element in the JavaScript. We’ll modify the deleteTodo() function as follows:

function deleteTodo(key) {
  todoItems = todoItems.filter(item => item.id !== Number(key));
  const item = document.querySelector(`[data-key='${key}']`);
  item.remove();

  // select the list element and trim all whitespace once there are no todo items left
  const list = document.querySelector('.js-todo-list');
  if (todoItems.length === 0) list.innerHTML = '';
}

The above code solves the problem, and the app now works as expected.

Final application

Wrap up

In this article, I showed you how to build a simple todo list app that allows the user to add new tasks, mark a task as completed and delete old tasks. I also discussed the importance of accounting for empty states when designing an application, then proceeded to talk about a potential problem when using the :empty selector and how to fix it.

I hope you learnt something. If a section or piece of code is not clear to you, feel free to leave a comment below and I’ll get back to you as soon as possible.

Thanks for reading!