Ad – 728Γ—90
πŸ› οΈ Projects

JavaScript Todo App – Build a Full-Featured Todo List

Build a complete todo list application using only vanilla JavaScript β€” no frameworks, no libraries. Features include add, complete, delete, filter (all/active/completed), item count, and full localStorage persistence so todos survive page refreshes.

⏱️ 30 min read 🎯 Advanced πŸ“… Updated 2026

Project Overview

The Todo App is one of the most valuable beginner-to-intermediate projects because it covers nearly every core JavaScript concept: DOM manipulation, events, array methods, localStorage, and state-driven rendering. Understanding this pattern is the mental model behind React, Vue, and every other major UI framework.

FeatureTechnique
Add todoPush to array, re-render
Mark completeToggle done boolean with .map()
Delete todoFilter out by ID with .filter()
Filter viewFilter array before rendering
Item countFilter for incomplete, display length
PersistenceJSON stringify/parse to localStorage
Clear completedFilter out done todos

HTML Structure

JavaScript
/* todo.html β€” full HTML skeleton

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Todo App</title>
  <link rel="stylesheet" href="todo-style.css">
</head>
<body>
  <div class="app">
    <header class="app-header">
      <h1>My Todos</h1>
      <span id="count-badge">0 left</span>
    </header>

    <div class="input-row">
      <input id="todo-input" type="text" placeholder="What needs to be done?" autocomplete="off">
      <button id="add-btn">Add</button>
    </div>

    <nav class="filter-bar" id="filter-bar">
      <button class="active" data-filter="all">All</button>
      <button data-filter="active">Active</button>
      <button data-filter="completed">Completed</button>
    </nav>

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

    <footer class="app-footer">
      <button id="clear-completed">Clear completed</button>
    </footer>
  </div>
  <script src="todo-script.js"></script>
</body>
</html>
*/

State Management

The entire application state lives in two variables: the todos array and the current filter string. Every user action updates state and then calls render() β€” a pure function that rebuilds the DOM from the current state.

JavaScript
// todo-script.js β€” State and persistence layer

const STORAGE_KEY = "ylearner-todos";

let todos  = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
let filter = "all"; // "all" | "active" | "completed"

function persist() {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}

// State mutation helpers β€” always call render() after
function addTodo(text) {
  todos.push({
    id:        Date.now(),
    text:      text.trim(),
    done:      false,
    createdAt: Date.now()
  });
  persist();
}

function toggleTodo(id) {
  todos = todos.map(t => t.id === id ? { ...t, done: !t.done } : t);
  persist();
}

function deleteTodo(id) {
  todos = todos.filter(t => t.id !== id);
  persist();
}

function editTodo(id, newText) {
  todos = todos.map(t => t.id === id ? { ...t, text: newText } : t);
  persist();
}

function clearCompleted() {
  todos = todos.filter(t => !t.done);
  persist();
}

The Render Function

The render function is the heart of the app. It reads from state and rebuilds the visible DOM. This "state β†’ DOM" pattern is exactly how React's virtual DOM works.

JavaScript
// Render β€” reads state, rebuilds DOM
function render() {
  const list  = document.getElementById("todo-list");
  const badge = document.getElementById("count-badge");

  // Apply active filter
  const visible = todos.filter(t => {
    if (filter === "active")    return !t.done;
    if (filter === "completed") return t.done;
    return true;
  });

  // Render list
  if (visible.length === 0) {
    list.innerHTML = `<li class="empty-state">
      ${filter === "completed" ? "No completed todos yet." : "Nothing to do β€” enjoy your day!"}
    </li>`;
  } else {
    list.innerHTML = visible.map(t => `
      <li class="todo-item ${t.done ? "done" : ""}" data-id="${t.id}">
        <button class="toggle" aria-label="Toggle complete">
          ${t.done ? "βœ…" : "⬜"}
        </button>
        <span class="todo-text" contenteditable="false">${escapeHtml(t.text)}</span>
        <button class="edit-btn" title="Edit">✏️</button>
        <button class="delete-btn" aria-label="Delete todo">πŸ—‘οΈ</button>
      </li>
    `).join("");
  }

  // Update item count (count only incomplete)
  const remaining = todos.filter(t => !t.done).length;
  badge.textContent = `${remaining} item${remaining !== 1 ? "s" : ""} left`;

  // Highlight active filter button
  document.querySelectorAll("#filter-bar button").forEach(btn => {
    btn.classList.toggle("active", btn.dataset.filter === filter);
  });
}

// Prevent XSS when rendering user-entered text
function escapeHtml(str) {
  return str
    .replace(/&/g, "&")
    .replace(//g, ">")
    .replace(/"/g, """);
}

Event Handlers

JavaScript
// Add todo on button click or Enter key
const input  = document.getElementById("todo-input");
const addBtn = document.getElementById("add-btn");

function handleAdd() {
  const text = input.value.trim();
  if (!text) return;
  addTodo(text);
  input.value = "";
  render();
  input.focus();
}

addBtn.addEventListener("click", handleAdd);
input.addEventListener("keydown", e => {
  if (e.key === "Enter") handleAdd();
});

// Filter buttons
document.getElementById("filter-bar").addEventListener("click", e => {
  if (e.target.matches("button[data-filter]")) {
    filter = e.target.dataset.filter;
    render();
  }
});

// Clear completed
document.getElementById("clear-completed").addEventListener("click", () => {
  clearCompleted();
  render();
});

// Delegated events for todo list (toggle, delete, edit)
document.getElementById("todo-list").addEventListener("click", e => {
  const li = e.target.closest("[data-id]");
  if (!li) return;
  const id = Number(li.dataset.id);

  if (e.target.matches(".toggle")) {
    toggleTodo(id);
    render();
  }

  if (e.target.matches(".delete-btn")) {
    // Animate removal
    li.classList.add("removing");
    setTimeout(() => { deleteTodo(id); render(); }, 200);
  }

  if (e.target.matches(".edit-btn")) {
    const textEl = li.querySelector(".todo-text");
    textEl.contentEditable = "true";
    textEl.focus();

    // Place cursor at end
    const range = document.createRange();
    range.selectNodeContents(textEl);
    range.collapse(false);
    window.getSelection().removeAllRanges();
    window.getSelection().addRange(range);

    textEl.addEventListener("blur", () => {
      const newText = textEl.textContent.trim();
      if (newText) editTodo(id, newText);
      else deleteTodo(id);
      render();
    }, { once: true });

    textEl.addEventListener("keydown", e => {
      if (e.key === "Enter") { e.preventDefault(); textEl.blur(); }
      if (e.key === "Escape") { render(); } // discard changes
    }, { once: true });
  }
});

// Initial render
render();
Behavior: On load, todos are read from localStorage and rendered. Adding "Buy groceries" shows it in the list with a toggle checkbox and delete button. Pressing "Active" filter hides completed items. Refreshing the page restores all todos.
XSS Warning: Always escape user-entered text before inserting it into innerHTML. The escapeHtml() function prevents an attacker from entering <img src=x onerror=alert(1)> as a todo text and executing JavaScript. Alternatively, use textContent property instead of HTML template literals for text nodes.
Performance Tip: Full re-renders (wiping innerHTML and rebuilding) are simple and correct for small lists (< 100 items). For larger lists, use a diffing strategy β€” compare the new virtual state to the rendered DOM and only update changed nodes. This is exactly what React's reconciliation does.
Ad – 336Γ—280

CSS Highlights

JavaScript
/* Key CSS patterns for the Todo App

.todo-item {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 16px;
  border-bottom: 1px solid #eee;
  transition: opacity 0.2s, transform 0.2s;
}
.todo-item.done .todo-text {
  text-decoration: line-through;
  color: #a0aec0;
}
.todo-item.removing {
  opacity: 0;
  transform: translateX(20px);
}
.filter-bar button.active {
  border-bottom: 2px solid #3b82f6;
  color: #3b82f6;
  font-weight: 600;
}
.empty-state {
  text-align: center;
  color: #a0aec0;
  padding: 40px;
  list-style: none;
}
*/

Enhancements

Extend the App

Once the base version works, add these features to build a stronger portfolio piece:

  1. Drag-and-drop reordering β€” use the HTML Drag and Drop API to let users reorder todos
  2. Priority levels β€” add a low/medium/high priority badge to each todo
  3. Due dates β€” add a date picker and highlight overdue todos in red
  4. Search β€” live-filter todos by text as the user types in a search box
  5. Keyboard shortcuts β€” press e to edit the selected todo, Delete to remove it

Architecture Challenge

Refactor the app using the module pattern to separate concerns:

  • store.js β€” state, mutations, and persistence
  • render.js β€” all DOM rendering functions
  • events.js β€” all event listener setup
  • main.js β€” import all modules and initialise

This introduces you to module design thinking used in production JavaScript.

Concepts Practiced

ConceptWhere Used
DOM manipulationinnerHTML rendering, querying elements
Event delegationSingle listener on #todo-list
Array methods.map(), .filter(), .push()
localStorageJSON save/load on every state mutation
ClosuresState variables captured by event handlers
contentEditableInline text editing without input fields
CSS transitionsRemoval animation via class toggling

FAQ

Why rebuild the entire list on every change instead of updating individual items?

Full re-renders are simpler to reason about β€” the DOM always exactly reflects the state. For small lists this is performant enough. It's also the conceptual foundation of React β€” understand this first before learning virtual DOM diffing.

Why use Date.now() as the todo ID?

It's a quick, monotonically increasing integer β€” guaranteed unique as long as users don't add multiple todos in the same millisecond. For multi-user or backend-synced apps, use UUIDs (crypto.randomUUID() in modern browsers).

How does the edit feature work without an input field?

contenteditable="true" makes any element editable inline. The blur event fires when the user clicks away, and textContent reads the edited text. This avoids replacing the span with an input field and back.

What happens if the user has 1000 todos in localStorage?

localStorage can hold about 5MB. 1000 todos at ~200 bytes each is 200KB β€” well within limits. Performance-wise, rendering 1000 items at once can be slow; at that scale, implement virtual scrolling (only render visible items).

How would I sync todos across devices?

Replace localStorage calls with API calls to a backend (Node.js + MongoDB, Firebase, Supabase). On load, fetch todos from the server. On every change, send a PATCH/POST/DELETE request.

Summary

  • Single source of truth: the todos array + filter string
  • State-driven rendering: every action updates state, then calls render()
  • localStorage persistence: JSON stringify/parse on every mutation
  • Event delegation: one listener handles toggle, delete, and edit for all todos
  • Security: escapeHtml() prevents XSS from user-entered todo text
  • The state β†’ render pattern here is identical to React's model β€” understanding it natively is a huge advantage
  • Event delegation is a must-know pattern: always preferred over attaching listeners to dynamic elements
  • localStorage persistence shows understanding of the browser storage API
  • XSS prevention (escaping user input) demonstrates security awareness interviewers value