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.