Todo App

CRUD mastery. Create, read, update, delete — with persistence and filtering.

Architecture

Separate data, logic, and rendering cleanly.

// Architecture: Model → Controller → View
//
// Model: Array of todo objects (the data)
// Controller: Functions that modify the data
// View: Functions that render the DOM
//
// Flow: User action → update model → re-render view
//
// Project structure:
// todo-app/
// ├── index.html
// ├── style.css
// └── app.js

// HTML skeleton:
// <div class="app">
//   <h1>Todos</h1>
//   <form class="todo-form">
//     <input type="text" placeholder="What needs to be done?">
//     <button type="submit">Add</button>
//   </form>
//   <div class="filters">
//     <button data-filter="all" class="active">All</button>
//     <button data-filter="active">Active</button>
//     <button data-filter="completed">Completed</button>
//   </div>
//   <ul class="todo-list"></ul>
//   <div class="footer">
//     <span class="count"></span>
//     <button class="clear-completed">Clear completed</button>
//   </div>
// </div>

Data Model

Each todo is an object with id, text, and completed status.

// app.js — Data layer:
let todos = [];
let currentFilter = "all";

// Todo shape:
// {
//   id: "a1b2c3",        — unique identifier
//   text: "Buy groceries", — the task description
//   completed: false,      — toggle state
//   createdAt: 1704067200  — timestamp for ordering
// }

function createTodo(text) {
  return {
    id: crypto.randomUUID(),
    text: text.trim(),
    completed: false,
    createdAt: Date.now(),
  };
}

// Filtered views (never mutate the source array):
function getFilteredTodos() {
  switch (currentFilter) {
    case "active":
      return todos.filter(t => !t.completed);
    case "completed":
      return todos.filter(t => t.completed);
    default:
      return todos;
  }
}

function getActiveTodoCount() {
  return todos.filter(t => !t.completed).length;
}

Rendering

One render function rebuilds the list from data.

// DOM references:
const form = document.querySelector(".todo-form");
const input = form.querySelector("input");
const list = document.querySelector(".todo-list");
const countEl = document.querySelector(".count");
const filterButtons = document.querySelectorAll("[data-filter]");

// The single render function:
function render() {
  const filtered = getFilteredTodos();

  // Rebuild the list:
  list.innerHTML = filtered.map(todo => `
    <li class="todo-item ${todo.completed ? "completed" : ""}" data-id="${todo.id}">
      <label class="checkbox">
        <input type="checkbox" ${todo.completed ? "checked" : ""}>
        <span class="checkmark"></span>
      </label>
      <span class="todo-text">${escapeHtml(todo.text)}</span>
      <button class="edit-btn" aria-label="Edit">✎</button>
      <button class="delete-btn" aria-label="Delete">×</button>
    </li>
  `).join("");

  // Update counter:
  const count = getActiveTodoCount();
  countEl.textContent = `${count} item${count !== 1 ? "s" : ""} left`;

  // Update filter buttons:
  filterButtons.forEach(btn => {
    btn.classList.toggle("active", btn.dataset.filter === currentFilter);
  });

  // Save to localStorage:
  saveTodos();
}

// XSS prevention — ALWAYS escape user content:
function escapeHtml(str) {
  const div = document.createElement("div");
  div.textContent = str;
  return div.innerHTML;
}

CRUD Operations

Create, toggle, edit, delete, and filter.

// CREATE — form submit:
form.addEventListener("submit", (e) => {
  e.preventDefault();
  const text = input.value.trim();
  if (!text) return;

  todos.push(createTodo(text));
  input.value = "";
  render();
});

// DELETE & TOGGLE — event delegation on the list:
list.addEventListener("click", (e) => {
  const item = e.target.closest(".todo-item");
  if (!item) return;
  const id = item.dataset.id;

  // Delete:
  if (e.target.closest(".delete-btn")) {
    todos = todos.filter(t => t.id !== id);
    render();
    return;
  }

  // Toggle complete:
  if (e.target.closest(".checkbox")) {
    const todo = todos.find(t => t.id === id);
    if (todo) todo.completed = !todo.completed;
    render();
  }
});

Create adds to the array. Delete filters it out. Toggle flips the completed boolean. All end with render().

1 / 2

Persistence

Save to localStorage so todos survive page reload.

const STORAGE_KEY = "todo-app-data";

function saveTodos() {
  const data = JSON.stringify({ todos, currentFilter });
  localStorage.setItem(STORAGE_KEY, data);
}

function loadTodos() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return;
    const data = JSON.parse(raw);
    // Validate structure before using:
    if (Array.isArray(data.todos)) {
      todos = data.todos.filter(t =>
        typeof t.id === "string" &&
        typeof t.text === "string" &&
        typeof t.completed === "boolean"
      );
    }
    if (["all", "active", "completed"].includes(data.currentFilter)) {
      currentFilter = data.currentFilter;
    }
  } catch {
    // Corrupted data — start fresh
    todos = [];
  }
}

// Initialize:
loadTodos();
render();

// Key patterns in this project:
// ✓ CRUD operations on an array
// ✓ Data-driven rendering (render from state, not DOM)
// ✓ Event delegation for dynamic elements
// ✓ XSS prevention (escapeHtml)
// ✓ localStorage with validation
// ✓ Defensive parsing (try/catch, type checks)
// ✓ Immutable updates where possible (filter creates new array)

FAQ

Common questions about this project.