DocsHub
JavascriptIntermediate

Todo List

Build a fully functional todo list where users can add, complete, and delete tasks.

Todo List

Problem

Build a todo list where the user can add tasks, mark them as complete, delete them, and filter between all, active, and completed tasks. Tasks should persist in localStorage so they survive a page refresh.

User adds "Buy groceries"
→ Task appears in the list — active

User adds "Write notes"
→ Second task appears

User clicks "Buy groceries"
→ Task is marked complete — strikethrough

User clicks filter "Completed"
→ Only "Buy groceries" is visible

User clicks filter "Active"
→ Only "Write notes" is visible

User refreshes the page
→ Both tasks are still there

Logic

  1. Load any saved tasks from localStorage on page load
  2. Listen for form submit to add new tasks
  3. Each task has an id, text, and completed state
  4. Render the task list based on the current filter
  5. Clicking a task toggles its completed state
  6. Clicking the delete button removes the task
  7. Filter buttons show all, active, or completed tasks
  8. Save the tasks array to localStorage after every change

Flow

Add task Click task Delete task Click filter Page loads Load tasks from localStorage Render task list User action? Push new task to array Toggle completed state Remove task from array Update active filter Save to localStorage

HTML Structure

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Todo List</title>
    <style>
      body {
        font-family: sans-serif;
        max-width: 480px;
        margin: 60px auto;
        padding: 0 20px;
      }

      /* input row — text field and add button */
      .input-row {
        display: flex;
        gap: 10px;
        margin-bottom: 16px;
      }

      .input-row input {
        flex: 1;
        padding: 12px;
        font-size: 1rem;
        border: 2px solid #ccc;
        border-radius: 8px;
        outline: none;
        box-sizing: border-box;
      }

      .input-row input:focus {
        border-color: #555;
      }

      .input-row button {
        padding: 12px 20px;
        font-size: 1rem;
        font-weight: 600;
        background: #111;
        color: #fff;
        border: none;
        border-radius: 8px;
        cursor: pointer;
      }

      .input-row button:hover {
        opacity: 0.85;
      }

      /* filter buttons row */
      .filters {
        display: flex;
        gap: 8px;
        margin-bottom: 16px;
      }

      .filters button {
        padding: 6px 16px;
        border: 2px solid #ddd;
        border-radius: 20px;
        background: #fff;
        font-size: 0.85rem;
        cursor: pointer;
        color: #555;
      }

      .filters button.active {
        border-color: #111;
        background: #111;
        color: #fff;
      }

      /* task count */
      #task-count {
        font-size: 0.85rem;
        color: #888;
        margin-bottom: 12px;
      }

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

      /* each task row */
      #todo-list li {
        display: flex;
        align-items: center;
        gap: 12px;
        padding: 12px 14px;
        border: 1px solid #e0e0e0;
        border-radius: 8px;
        margin-bottom: 8px;
        cursor: pointer;
        transition: background 0.15s;
      }

      #todo-list li:hover {
        background: #f9f9f9;
      }

      /* completed task — strikethrough and grey */
      #todo-list li.completed .task-text {
        text-decoration: line-through;
        color: #aaa;
      }

      .task-text {
        flex: 1;
        font-size: 1rem;
        color: #333;
      }

      /* delete button on each task */
      .delete-btn {
        background: none;
        border: none;
        cursor: pointer;
        font-size: 1.1rem;
        color: #ccc;
        padding: 0;
        line-height: 1;
      }

      .delete-btn:hover {
        color: #e74c3c;
      }

      /* empty state message */
      #empty-message {
        text-align: center;
        color: #aaa;
        font-size: 0.95rem;
        padding: 24px 0;
        display: none;
      }
    </style>
  </head>
  <body>
    <h1>Todo List</h1>

    <!-- add task form -->
    <div class="input-row">
      <input type="text" id="task-input" placeholder="Add a new task..." />
      <button id="add-btn">Add</button>
    </div>

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

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

    <!-- tasks rendered here -->
    <ul id="todo-list"></ul>

    <!-- shown when list is empty -->
    <div id="empty-message">No tasks here.</div>

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

Solution

// Step 1 — select elements
const taskInput = document.querySelector("#task-input");
const addBtn = document.querySelector("#add-btn");
const todoList = document.querySelector("#todo-list");
const taskCount = document.querySelector("#task-count");
const emptyMessage = document.querySelector("#empty-message");
const filterButtons = document.querySelectorAll(".filters button");

// Step 2 — load tasks from localStorage
// JSON.parse converts the saved string back into an array
// if nothing is saved yet — start with an empty array
let tasks = JSON.parse(localStorage.getItem("tasks")) || [];

// Step 3 — track which filter is active — "all", "active", or "completed"
let currentFilter = "all";

// Step 4 — render tasks on page load
renderTasks();

// Step 5 — add task on button click
addBtn.addEventListener("click", addTask);

// also add on Enter key
taskInput.addEventListener("keydown", (e) => {
  if (e.key === "Enter") addTask();
});

function addTask() {
  const text = taskInput.value.trim();

  // do nothing if input is empty
  if (!text) return;

  // create a new task object
  const newTask = {
    id: Date.now(),       // unique id based on timestamp
    text: text,           // the task text
    completed: false      // new tasks start as active
  };

  // add to the tasks array
  tasks.push(newTask);

  // clear the input field
  taskInput.value = "";

  // save and re-render
  saveAndRender();
}

// Step 6 — use event delegation on the list
// one listener handles clicks on tasks and delete buttons
todoList.addEventListener("click", (e) => {
  // find the closest li — handles clicks on child elements too
  const li = e.target.closest("li");
  if (!li) return;

  const id = Number(li.dataset.id); // read task id from data attribute

  if (e.target.classList.contains("delete-btn")) {
    // Step 7 — delete task — filter it out of the array
    tasks = tasks.filter((task) => task.id !== id);
  } else {
    // Step 8 — toggle completed state
    const task = tasks.find((task) => task.id === id);
    if (task) task.completed = !task.completed;
  }

  saveAndRender();
});

// Step 9 — filter buttons
filterButtons.forEach((btn) => {
  btn.addEventListener("click", () => {
    // update active filter
    currentFilter = btn.dataset.filter;

    // update active button style
    filterButtons.forEach((b) => b.classList.remove("active"));
    btn.classList.add("active");

    renderTasks();
  });
});

function renderTasks() {
  // Step 10 — filter tasks based on current filter
  const filtered = tasks.filter((task) => {
    if (currentFilter === "active") return !task.completed;
    if (currentFilter === "completed") return task.completed;
    return true; // "all" — show everything
  });

  // clear the list
  todoList.innerHTML = "";

  // Step 11 — show empty message if nothing to show
  if (filtered.length === 0) {
    emptyMessage.style.display = "block";
    taskCount.textContent = "";
  } else {
    emptyMessage.style.display = "none";

    // update count — only count active tasks
    const activeCount = tasks.filter((t) => !t.completed).length;
    taskCount.textContent = `${activeCount} task${activeCount !== 1 ? "s" : ""} remaining`;

    // Step 12 — render each task as a list item
    filtered.forEach((task) => {
      const li = document.createElement("li");

      // store task id on the element — used in the click handler
      li.dataset.id = task.id;

      // add completed class if task is done
      if (task.completed) li.classList.add("completed");

      li.innerHTML = `
        <span class="task-text">${task.text}</span>
        <button class="delete-btn" title="Delete task">✕</button>
      `;

      todoList.appendChild(li);
    });
  }
}

// Step 13 — save to localStorage and re-render
function saveAndRender() {
  // JSON.stringify converts the array to a string for storage
  localStorage.setItem("tasks", JSON.stringify(tasks));
  renderTasks();
}

Code Execution

Trace through adding task "Buy groceries":

StepCodeResult
Read inputtaskInput.value.trim()"Buy groceries"
Create task{ id: 1710000000000, text: "Buy groceries", completed: false }new object
Push to arraytasks.push(newTask)tasks.length === 1
SavelocalStorage.setItem("tasks", JSON.stringify(tasks))saved to browser
RenderrenderTasks()task appears in list

Trace through clicking a task to complete it:

StepCodeResult
Click firese.target.closest("li")the li element
Read idli.dataset.id"1710000000000"
Find tasktasks.find(t => t.id === 1710000000000)the task object
Toggletask.completed = !falsetrue
Save and rendertask shows strikethrough

Trace through filter "completed":

StepCodeResult
Filtertasks.filter(t => t.completed)only completed tasks
Renderonly completed tasks shownactive tasks hidden

Output

Add "Buy groceries"     → appears in list, 1 task remaining
Add "Write notes"       → appears in list, 2 tasks remaining
Click "Buy groceries"   → strikethrough, 1 task remaining
Filter: Completed       → only "Buy groceries" visible
Filter: Active          → only "Write notes" visible
Filter: All             → both tasks visible
Refresh page            → both tasks still there (localStorage)

On this page