Build a To-Do App with JavaScript

Build a To-Do App with JavaScript

A to-do app looks simple at first glance. You type a task, click a button, and the item appears in a list. But behind that tiny interaction is a very useful set of front-end skills: DOM manipulation, event handling, state management, local storage, rendering logic, and clean user experience design.

That is exactly why a to-do app is one of the best beginner projects in JavaScript.

It teaches you how to think like a developer, not just how to write code. You start with a blank page, then gradually give it life. By the end, you will have something that feels real and useful, not just a tutorial toy.

In this article, we will build a complete to-do app step by step using vanilla JavaScript. No frameworks. No complicated setup. Just HTML, CSS, and JavaScript working together in a practical way.

We will build features such as adding tasks, marking them as completed, deleting tasks, editing tasks, filtering tasks, clearing completed tasks, and saving everything in the browser so the data remains after refresh.


What We Are Building

Our final app will include:

  • Adding new tasks

  • Displaying all tasks

  • Marking tasks as done

  • Editing task text

  • Deleting a task

  • Filtering by all, active, and completed

  • Clearing completed tasks

  • Saving tasks in local storage

  • A clean and responsive interface

This project is small enough to finish in one sitting, but rich enough to teach important JavaScript concepts that show up again and again in real projects.


Why Build a To-Do App?

A to-do app is a classic beginner project, but it is not boring. It is practical.

Here is why it matters:

  • It helps you understand how data is stored and updated in the browser.

  • It teaches you how to connect the user interface with JavaScript logic.

  • It gives you practice with arrays, objects, and functions.

  • It introduces event-driven programming in a very natural way.

  • It is easy to improve later with new features.

The best part is that you can keep extending it. Once the basic version is working, you can add due dates, priority levels, categories, drag-and-drop ordering, dark mode, reminders, or even sync with a backend API.


Project Structure

We will keep the project simple with three files:

todo-app/
│
├── index.html
├── style.css
└── script.js

This is perfect for learning because each file has a clear role:

  • index.html holds the structure

  • style.css handles the design

  • script.js handles the behavior


Step 1: Create the HTML Structure

Let us begin with the HTML. We need a form for adding tasks, a list to display them, and controls for filtering and clearing tasks.

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>To-Do App</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <div class="app-container">
    <h1>To-Do App</h1>

    <form id="todo-form">
      <input
        type="text"
        id="todo-input"
        placeholder="Add a new task..."
        autocomplete="off"
      />
      <button type="submit">Add</button>
    </form>

    <div class="controls">
      <div class="filters">
        <button class="filter-btn active" data-filter="all">All</button>
        <button class="filter-btn" data-filter="active">Active</button>
        <button class="filter-btn" data-filter="completed">Completed</button>
      </div>

      <button id="clear-completed">Clear Completed</button>
    </div>

    <ul id="todo-list"></ul>

    <p id="task-count"></p>
  </div>

  <script src="script.js"></script>
</body>
</html>

What this HTML does

We created:

  • A wrapper container for the app

  • A title

  • A form with an input and button

  • Filter buttons

  • A clear completed button

  • An unordered list for tasks

  • A paragraph for showing task count

This is already enough to start building the logic in JavaScript.


Step 2: Add the CSS Styling

A to-do app does not need to be fancy, but it should be pleasant to use. Good design makes the app feel complete and easier to interact with.

style.css

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, sans-serif;
  background: linear-gradient(135deg, #f5f7fa, #c3cfe2);
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 20px;
}

.app-container {
  background: white;
  width: 100%;
  max-width: 600px;
  border-radius: 16px;
  padding: 24px;
  box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1);
}

h1 {
  text-align: center;
  margin-top: 0;
  color: #222;
}

form {
  display: flex;
  gap: 10px;
  margin-bottom: 16px;
}

#todo-input {
  flex: 1;
  padding: 12px 14px;
  border: 1px solid #ccc;
  border-radius: 10px;
  font-size: 16px;
}

button {
  padding: 12px 16px;
  border: none;
  border-radius: 10px;
  cursor: pointer;
  font-size: 14px;
  background: #4f46e5;
  color: white;
}

button:hover {
  opacity: 0.9;
}

.controls {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 16px;
  flex-wrap: wrap;
}

.filters {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

.filter-btn {
  background: #e5e7eb;
  color: #111827;
}

.filter-btn.active {
  background: #111827;
  color: white;
}

#clear-completed {
  background: #ef4444;
}

#todo-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.todo-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 14px;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  margin-bottom: 10px;
  background: #fafafa;
}

.todo-text {
  flex: 1;
  margin-right: 12px;
  word-break: break-word;
}

.todo-item.completed .todo-text {
  text-decoration: line-through;
  color: #6b7280;
}

.todo-actions {
  display: flex;
  gap: 8px;
}

.todo-actions button {
  padding: 8px 10px;
  font-size: 13px;
}

.edit-btn {
  background: #f59e0b;
}

.delete-btn {
  background: #dc2626;
}

.complete-btn {
  background: #10b981;
}

#task-count {
  margin-top: 16px;
  text-align: center;
  color: #374151;
  font-size: 14px;
}

input:focus,
button:focus {
  outline: 2px solid #93c5fd;
  outline-offset: 2px;
}

@media (max-width: 600px) {
  form {
    flex-direction: column;
  }

  .controls {
    flex-direction: column;
  }

  .filters {
    width: 100%;
  }

  .filters button,
  #clear-completed {
    width: 100%;
  }
}

Why this styling works

The CSS keeps the app clean and readable:

  • A centered card layout

  • A soft background gradient

  • Clear button colors

  • Mobile-friendly responsiveness

  • Completed tasks appear crossed out

  • Buttons are visually distinct

This is enough to make the project feel polished without being over-designed.


Step 3: Understand the Data Model

Before writing JavaScript, it helps to decide how tasks will be stored.

Each task can be represented as an object like this:

{
  id: 123456789,
  text: "Learn JavaScript",
  completed: false
}

Why use objects?

Because tasks have more than one piece of data:

  • the text

  • whether the task is completed

  • a unique identifier

We will keep all tasks inside an array:

let todos = [];

That array will act as our in-memory database.


Step 4: Start the JavaScript File

script.js

Let us begin by selecting the DOM elements we need.

const todoForm = document.getElementById("todo-form");
const todoInput = document.getElementById("todo-input");
const todoList = document.getElementById("todo-list");
const taskCount = document.getElementById("task-count");
const clearCompletedBtn = document.getElementById("clear-completed");
const filterButtons = document.querySelectorAll(".filter-btn");

Now create the main variables:

let todos = [];
let currentFilter = "all";

We now have:

  • the form

  • the input

  • the task list container

  • the buttons

  • an array for tasks

  • a filter state


Step 5: Load Tasks from Local Storage

Local storage lets us save data in the browser. That way, tasks stay even after a page refresh.

Save data

function saveTodos() {
  localStorage.setItem("todos", JSON.stringify(todos));
}

Load data

function loadTodos() {
  const storedTodos = localStorage.getItem("todos");

  if (storedTodos) {
    todos = JSON.parse(storedTodos);
  }
}

We will call loadTodos() when the app starts.

loadTodos();

This is one of the most useful lessons in JavaScript: when data changes, you can store it and recover it later.


Step 6: Render the Task List

Now we need a function that shows tasks on the screen.

Render function

function renderTodos() {
  todoList.innerHTML = "";

  const filteredTodos = todos.filter((todo) => {
    if (currentFilter === "active") return !todo.completed;
    if (currentFilter === "completed") return todo.completed;
    return true;
  });

  filteredTodos.forEach((todo) => {
    const li = document.createElement("li");
    li.className = `todo-item ${todo.completed ? "completed" : ""}`;
    li.dataset.id = todo.id;

    li.innerHTML = `
      <span class="todo-text">${todo.text}</span>
      <div class="todo-actions">
        <button class="complete-btn">${todo.completed ? "Undo" : "Done"}</button>
        <button class="edit-btn">Edit</button>
        <button class="delete-btn">Delete</button>
      </div>
    `;

    todoList.appendChild(li);
  });

  updateTaskCount();
}

What this function does

  • Clears the existing list

  • Filters tasks based on selected filter

  • Creates a list item for each task

  • Adds buttons for completing, editing, and deleting

  • Updates the task count

This is the core of the app.

Whenever something changes, we call renderTodos() again.


Step 7: Add a New Task

Now let us handle the form submission.

todoForm.addEventListener("submit", function (e) {
  e.preventDefault();

  const taskText = todoInput.value.trim();

  if (taskText === "") {
    alert("Please enter a task.");
    return;
  }

  const newTodo = {
    id: Date.now(),
    text: taskText,
    completed: false,
  };

  todos.push(newTodo);
  todoInput.value = "";

  saveTodos();
  renderTodos();
});

How this works

  • Prevent the form from refreshing the page

  • Read the input value

  • Remove extra spaces

  • Check whether the task is empty

  • Create a new task object

  • Add it to the array

  • Save and re-render

This is one of those places where JavaScript feels instantly rewarding. One small submission becomes visible immediately.


Step 8: Handle Task Actions with Event Delegation

Each task has buttons. Instead of attaching listeners to every button one by one, we can use event delegation on the parent list.

This is cleaner and more efficient.

todoList.addEventListener("click", function (e) {
  const target = e.target;
  const todoItem = target.closest(".todo-item");

  if (!todoItem) return;

  const id = Number(todoItem.dataset.id);

  if (target.classList.contains("complete-btn")) {
    toggleTodo(id);
  }

  if (target.classList.contains("delete-btn")) {
    deleteTodo(id);
  }

  if (target.classList.contains("edit-btn")) {
    editTodo(id);
  }
});

Why event delegation is useful

Instead of adding three listeners for every task, we add one listener to the parent <ul>. When a button is clicked, we detect which button it was.

This is a real-world technique you will use often.


Step 9: Toggle Completion Status

Let us add the function for marking tasks done or undoing them.

function toggleTodo(id) {
  todos = todos.map((todo) => {
    if (todo.id === id) {
      return { ...todo, completed: !todo.completed };
    }
    return todo;
  });

  saveTodos();
  renderTodos();
}

Explanation

This function loops through the array and updates only the matching task.

  • If the task matches the ID, reverse its completed value

  • Otherwise, keep it the same

We use .map() because it returns a new array, which is a clean and predictable way to update state.


Step 10: Delete a Task

Now let us remove tasks completely.

function deleteTodo(id) {
  todos = todos.filter((todo) => todo.id !== id);
  saveTodos();
  renderTodos();
}

This is simple and elegant:

  • Keep every task except the one we want to delete

  • Save the updated list

  • Re-render the UI


Step 11: Edit a Task

Editing is a very useful feature. People make mistakes, and tasks change.

Here is one simple way to do it using prompt().

function editTodo(id) {
  const todo = todos.find((todo) => todo.id === id);

  if (!todo) return;

  const newText = prompt("Edit your task:", todo.text);

  if (newText === null) return;

  const trimmedText = newText.trim();

  if (trimmedText === "") {
    alert("Task cannot be empty.");
    return;
  }

  todo.text = trimmedText;
  saveTodos();
  renderTodos();
}

Why this is okay for a beginner project

prompt() is not the most beautiful editing experience, but it keeps the project simple and focused on JavaScript logic.

Later, you can replace it with an inline editable input.


Step 12: Add Filtering

We already created filter buttons in HTML. Now we need to make them work.

filterButtons.forEach((button) => {
  button.addEventListener("click", function () {
    filterButtons.forEach((btn) => btn.classList.remove("active"));
    this.classList.add("active");

    currentFilter = this.dataset.filter;
    renderTodos();
  });
});

What the filters do

  • all shows everything

  • active shows tasks that are not completed

  • completed shows tasks that are completed

Filtering is one of the best examples of separating data from presentation. The array stays the same, but the display changes.


Step 13: Clear Completed Tasks

Now let us add a button that removes all completed tasks at once.

clearCompletedBtn.addEventListener("click", function () {
  todos = todos.filter((todo) => !todo.completed);
  saveTodos();
  renderTodos();
});

This is helpful when the list starts to grow. It lets the user clean up finished work quickly.


Step 14: Show Task Count

A small detail like task count makes the app feel more complete.

function updateTaskCount() {
  const activeTasks = todos.filter((todo) => !todo.completed).length;
  taskCount.textContent = `${activeTasks} task${activeTasks !== 1 ? "s" : ""} left`;
}

If there is one task left, it shows:

1 task left

If there are many:

5 tasks left

This tiny detail adds a human touch to the interface.


Step 15: Put Everything Together

Here is the full JavaScript file in one place.

script.js

const todoForm = document.getElementById("todo-form");
const todoInput = document.getElementById("todo-input");
const todoList = document.getElementById("todo-list");
const taskCount = document.getElementById("task-count");
const clearCompletedBtn = document.getElementById("clear-completed");
const filterButtons = document.querySelectorAll(".filter-btn");

let todos = [];
let currentFilter = "all";

function saveTodos() {
  localStorage.setItem("todos", JSON.stringify(todos));
}

function loadTodos() {
  const storedTodos = localStorage.getItem("todos");

  if (storedTodos) {
    todos = JSON.parse(storedTodos);
  }
}

function updateTaskCount() {
  const activeTasks = todos.filter((todo) => !todo.completed).length;
  taskCount.textContent = `${activeTasks} task${activeTasks !== 1 ? "s" : ""} left`;
}

function renderTodos() {
  todoList.innerHTML = "";

  const filteredTodos = todos.filter((todo) => {
    if (currentFilter === "active") return !todo.completed;
    if (currentFilter === "completed") return todo.completed;
    return true;
  });

  filteredTodos.forEach((todo) => {
    const li = document.createElement("li");
    li.className = `todo-item ${todo.completed ? "completed" : ""}`;
    li.dataset.id = todo.id;

    li.innerHTML = `
      <span class="todo-text">${todo.text}</span>
      <div class="todo-actions">
        <button class="complete-btn">${todo.completed ? "Undo" : "Done"}</button>
        <button class="edit-btn">Edit</button>
        <button class="delete-btn">Delete</button>
      </div>
    `;

    todoList.appendChild(li);
  });

  updateTaskCount();
}

function toggleTodo(id) {
  todos = todos.map((todo) => {
    if (todo.id === id) {
      return { ...todo, completed: !todo.completed };
    }
    return todo;
  });

  saveTodos();
  renderTodos();
}

function deleteTodo(id) {
  todos = todos.filter((todo) => todo.id !== id);
  saveTodos();
  renderTodos();
}

function editTodo(id) {
  const todo = todos.find((todo) => todo.id === id);

  if (!todo) return;

  const newText = prompt("Edit your task:", todo.text);

  if (newText === null) return;

  const trimmedText = newText.trim();

  if (trimmedText === "") {
    alert("Task cannot be empty.");
    return;
  }

  todo.text = trimmedText;
  saveTodos();
  renderTodos();
}

todoForm.addEventListener("submit", function (e) {
  e.preventDefault();

  const taskText = todoInput.value.trim();

  if (taskText === "") {
    alert("Please enter a task.");
    return;
  }

  const newTodo = {
    id: Date.now(),
    text: taskText,
    completed: false,
  };

  todos.push(newTodo);
  todoInput.value = "";

  saveTodos();
  renderTodos();
});

todoList.addEventListener("click", function (e) {
  const target = e.target;
  const todoItem = target.closest(".todo-item");

  if (!todoItem) return;

  const id = Number(todoItem.dataset.id);

  if (target.classList.contains("complete-btn")) {
    toggleTodo(id);
  }

  if (target.classList.contains("delete-btn")) {
    deleteTodo(id);
  }

  if (target.classList.contains("edit-btn")) {
    editTodo(id);
  }
});

filterButtons.forEach((button) => {
  button.addEventListener("click", function () {
    filterButtons.forEach((btn) => btn.classList.remove("active"));
    this.classList.add("active");

    currentFilter = this.dataset.filter;
    renderTodos();
  });
});

clearCompletedBtn.addEventListener("click", function () {
  todos = todos.filter((todo) => !todo.completed);
  saveTodos();
  renderTodos();
});

loadTodos();
renderTodos();

Step 16: How the App Works Under the Hood

Now that the app is built, let us slow down and look at the logic in a more thoughtful way.

1. State lives in an array

The todos array is the source of truth. Every task is stored there.

2. The UI is re-rendered from that array

Instead of manually changing the DOM in many places, we rebuild the list from the current data whenever something changes.

3. Local storage keeps the state persistent

Whenever the array changes, we save it to the browser.

4. Filters do not change the data

They only change which tasks are shown.

This separation is important. It keeps the code manageable as the app grows.


Step 17: Common Mistakes Beginners Make

When building a to-do app, people often run into the same issues. It is useful to know them early.

Forgetting preventDefault()

If you do not stop the form from submitting normally, the page refreshes.

e.preventDefault();

Not trimming input values

Users may accidentally type spaces before or after the task.

const taskText = todoInput.value.trim();

Using duplicate IDs

If you use a task name as the ID, you may run into problems when tasks repeat. A unique ID from Date.now() is a simple solution.

Not re-rendering after updates

If you change the array but forget to call renderTodos(), the screen will not reflect the new state.

Forgetting to save to local storage

If you do not call saveTodos(), the tasks disappear when the page reloads.

These small details matter a lot.


Step 18: Improving the User Experience

Now that the basic app works, let us think like a real product builder.

A good app is not just functional. It should feel smooth.

Add keyboard support

A user should be able to press Enter in the input and add a task. The form already handles this automatically.

Add empty-state feedback

When there are no tasks, show a friendly message.

function renderTodos() {
  todoList.innerHTML = "";

  const filteredTodos = todos.filter((todo) => {
    if (currentFilter === "active") return !todo.completed;
    if (currentFilter === "completed") return todo.completed;
    return true;
  });

  if (filteredTodos.length === 0) {
    todoList.innerHTML = `<li class="todo-item">No tasks to show.</li>`;
    updateTaskCount();
    return;
  }

  filteredTodos.forEach((todo) => {
    const li = document.createElement("li");
    li.className = `todo-item ${todo.completed ? "completed" : ""}`;
    li.dataset.id = todo.id;

    li.innerHTML = `
      <span class="todo-text">${todo.text}</span>
      <div class="todo-actions">
        <button class="complete-btn">${todo.completed ? "Undo" : "Done"}</button>
        <button class="edit-btn">Edit</button>
        <button class="delete-btn">Delete</button>
      </div>
    `;

    todoList.appendChild(li);
  });

  updateTaskCount();
}

Add confirmation before deleting

For a safer experience, ask before removing a task.

function deleteTodo(id) {
  const confirmDelete = confirm("Are you sure you want to delete this task?");
  if (!confirmDelete) return;

  todos = todos.filter((todo) => todo.id !== id);
  saveTodos();
  renderTodos();
}

Improve editing

Later, you can replace prompt() with a custom modal or inline input box.


Step 19: A More Advanced Version with Inline Editing

If you want the app to feel more modern, you can make the editing happen directly in the list.

This is more advanced, but it is a great next step.

Instead of this:

const newText = prompt("Edit your task:", todo.text);

You could create an input field inside the task item and save changes on blur or Enter.

That approach gives users a smoother experience, but it requires a little more DOM work. It is a perfect upgrade once you are comfortable with the simple version.


Step 20: Ideas for Extra Features

Once the app works, you can keep improving it. This is where the project becomes yours.

Here are some possible upgrades:

1. Search tasks

Add a search bar that filters tasks by keyword.

2. Drag and drop sorting

Let the user reorder tasks visually.

3. Due dates

Allow tasks to include deadlines.

4. Priority levels

Mark tasks as low, medium, or high priority.

5. Dark mode

Add a theme toggle for light and dark appearance.

6. Categories or tags

Organize tasks by work, personal, study, and so on.

7. Animations

Add smooth transitions when adding or deleting tasks.

8. Backend sync

Store tasks in a database instead of local storage.

These extensions are excellent practice because they build on the same foundation.


Step 21: Turning This Into a Real-World App

A real to-do app in production often needs more than browser storage.

You might eventually want:

  • user accounts

  • cloud sync

  • notifications

  • reminders

  • recurring tasks

  • offline support

  • export/import features

That is where the project starts to feel less like a tutorial and more like a product.

Even then, the same basic structure remains:

  • keep state in data

  • render from state

  • respond to user events

  • save changes somewhere persistent

That pattern appears in almost every front-end app.


Step 22: Why This Project Teaches Good JavaScript Habits

This project is small, but it teaches habits that matter a lot.

Clean separation of concerns

HTML for structure, CSS for appearance, JavaScript for behavior.

Reusable functions

We created separate functions for saving, loading, rendering, editing, and deleting.

Working with arrays

You used push, filter, map, find, and forEach.

DOM manipulation

You learned how to create elements, update text, and attach events.

State-driven UI

The UI comes from the data, not the other way around.

That is a very important mindset.


Step 23: Final Complete Thought

The nice thing about a to-do app is that it rewards every small improvement.

At first, it is just a list.

Then it becomes interactive.

Then it becomes persistent.

Then it becomes useful.

And somewhere in that process, something clicks. You stop feeling like you are just copying code and start feeling like you are building something with intention.

That moment matters.

A project like this can be a tiny bridge between “I am learning JavaScript” and “I can build something real with JavaScript.”

And honestly, that is a beautiful place to be.


Full Summary

We built a complete to-do app using plain JavaScript with:

  • HTML for structure

  • CSS for styling

  • JavaScript for behavior

  • local storage for persistence

  • event delegation for efficient interaction

  • filtering for task organization

  • editing, deleting, and completion toggles for full functionality

This is one of the best beginner projects because it teaches the fundamentals in a way that feels practical and satisfying.


Final Code Checklist

Before you move on, make sure your app includes:

  • A form to add tasks

  • A list to show tasks

  • Buttons for complete, edit, and delete

  • Filter buttons

  • A clear completed option

  • Task count

  • local storage saving and loading

If all of those pieces are working, congratulations. You have built a real JavaScript application.


Conclusion

Building a to-do app step by step is more than a beginner exercise. It is a training ground for real JavaScript thinking.

You learn how to manage state, respond to user input, update the DOM, and persist data. These are the exact building blocks behind much larger applications.

The first version does not need to be perfect. It just needs to work. After that, you can improve the design, refine the experience, and add features that make it feel personal.

That is the beauty of projects like this. They begin simply, but they can grow with you.