40 minLesson 33 of 35
Final Projects
Project: Interactive Todo App
Project: Interactive Todo App
Build a fully-featured todo application using vanilla JavaScript — no frameworks. This project exercises DOM manipulation, event handling, local storage, and clean code organization. The result is a polished, deployable app.
Features
- Add, complete, and delete tasks
- Filter by All / Active / Completed
- Persistent storage (survives page reload)
- Task count badge
- Clear all completed
- Keyboard support (Enter to add)
- Drag to reorder (bonus)
Project Structure
todo-app/
├── index.html
├── style.css
└── app.js
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Todo App</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="container">
<h1>Todo <span class="badge" id="count">0</span></h1>
<div class="input-row">
<input type="text" id="todo-input" placeholder="What needs to be done?" autocomplete="off">
<button id="add-btn">Add</button>
</div>
<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>
<ul id="todo-list"></ul>
<div class="footer" id="footer">
<span id="remaining"></span>
<button id="clear-completed">Clear completed</button>
</div>
</main>
<script src="app.js"></script>
</body>
</html>
app.js
// ─── State ─────────────────────────────────────────────────
const STORAGE_KEY = "todo-app-v1";
let state = {
todos: loadTodos(),
filter: "all",
nextId: loadNextId()
};
function loadTodos() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
} catch {
return [];
}
}
function loadNextId() {
const todos = loadTodos();
return todos.length > 0 ? Math.max(...todos.map(t => t.id)) + 1 : 1;
}
function saveTodos() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.todos));
}
// ─── Todo Operations ────────────────────────────────────────
function addTodo(text) {
const trimmed = text.trim();
if (!trimmed) return;
state.todos.unshift({
id: state.nextId++,
text: trimmed,
completed: false,
createdAt: Date.now()
});
saveTodos();
render();
}
function toggleTodo(id) {
const todo = state.todos.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed;
saveTodos();
render();
}
}
function deleteTodo(id) {
state.todos = state.todos.filter(t => t.id !== id);
saveTodos();
render();
}
function editTodo(id, newText) {
const todo = state.todos.find(t => t.id === id);
if (todo && newText.trim()) {
todo.text = newText.trim();
saveTodos();
render();
}
}
function clearCompleted() {
state.todos = state.todos.filter(t => !t.completed);
saveTodos();
render();
}
// ─── Filtering ──────────────────────────────────────────────
function getFilteredTodos() {
switch (state.filter) {
case "active": return state.todos.filter(t => !t.completed);
case "completed": return state.todos.filter(t => t.completed);
default: return state.todos;
}
}
// ─── Rendering ──────────────────────────────────────────────
function createTodoElement(todo) {
const li = document.createElement("li");
li.className = `todo-item ${todo.completed ? "completed" : ""}`;
li.dataset.id = todo.id;
li.innerHTML = `
<input type="checkbox" class="todo-check" ${todo.completed ? "checked" : ""}>
<span class="todo-text">${escapeHtml(todo.text)}</span>
<button class="delete-btn" aria-label="Delete todo">✕</button>
`;
return li;
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function render() {
const list = document.getElementById("todo-list");
const count = document.getElementById("count");
const remaining = document.getElementById("remaining");
const clearBtn = document.getElementById("clear-completed");
const footer = document.getElementById("footer");
const filtered = getFilteredTodos();
const activeCount = state.todos.filter(t => !t.completed).length;
const completedCount = state.todos.filter(t => t.completed).length;
// Update list
const fragment = document.createDocumentFragment();
filtered.forEach(todo => fragment.appendChild(createTodoElement(todo)));
list.innerHTML = "";
list.appendChild(fragment);
// Update counts
count.textContent = activeCount;
remaining.textContent = `${activeCount} item${activeCount !== 1 ? "s" : ""} left`;
// Show/hide clear button
clearBtn.style.display = completedCount > 0 ? "block" : "none";
footer.style.display = state.todos.length > 0 ? "flex" : "none";
// Update filter buttons
document.querySelectorAll(".filter-btn").forEach(btn => {
btn.classList.toggle("active", btn.dataset.filter === state.filter);
});
}
// ─── Event Listeners ────────────────────────────────────────
const input = document.getElementById("todo-input");
const addBtn = document.getElementById("add-btn");
addBtn.addEventListener("click", () => {
addTodo(input.value);
input.value = "";
input.focus();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
addTodo(input.value);
input.value = "";
}
});
// Event delegation for todo list
document.getElementById("todo-list").addEventListener("click", (e) => {
const li = e.target.closest(".todo-item");
if (!li) return;
const id = parseInt(li.dataset.id);
if (e.target.classList.contains("todo-check")) {
toggleTodo(id);
} else if (e.target.classList.contains("delete-btn")) {
li.classList.add("removing");
setTimeout(() => deleteTodo(id), 200); // animate then remove
}
});
// Double-click to edit
document.getElementById("todo-list").addEventListener("dblclick", (e) => {
const li = e.target.closest(".todo-item");
if (!li || !e.target.classList.contains("todo-text")) return;
const id = parseInt(li.dataset.id);
const span = e.target;
const currentText = span.textContent;
const editInput = document.createElement("input");
editInput.type = "text";
editInput.value = currentText;
editInput.className = "edit-input";
span.replaceWith(editInput);
editInput.focus();
editInput.select();
function finishEdit() {
editTodo(id, editInput.value || currentText);
}
editInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") finishEdit();
if (e.key === "Escape") editTodo(id, currentText);
});
editInput.addEventListener("blur", finishEdit);
});
// Filter buttons
document.querySelector(".filters").addEventListener("click", (e) => {
const btn = e.target.closest(".filter-btn");
if (!btn) return;
state.filter = btn.dataset.filter;
render();
});
document.getElementById("clear-completed").addEventListener("click", clearCompleted);
// ─── Initialize ─────────────────────────────────────────────
render();
input.focus();
style.css
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f5f5f5;
min-height: 100vh;
display: grid;
place-items: start center;
padding: 2rem 1rem;
}
.container { width: 100%; max-width: 560px; }
h1 {
font-size: 2.5rem;
font-weight: 300;
color: #d9d9d9;
text-align: center;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.badge {
background: #e74c3c;
color: white;
font-size: 1rem;
font-weight: 600;
border-radius: 999px;
padding: 2px 10px;
}
.input-row {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.input-row input {
flex: 1;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
outline: none;
}
.input-row input:focus { border-color: #4a90e2; box-shadow: 0 0 0 3px rgba(74,144,226,.2); }
.input-row button {
padding: 0.75rem 1.25rem;
background: #4a90e2;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
}
.filters {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.filter-btn {
padding: 0.4rem 0.9rem;
border: 1px solid transparent;
background: none;
border-radius: 6px;
cursor: pointer;
color: #555;
}
.filter-btn.active { border-color: #4a90e2; color: #4a90e2; }
#todo-list { list-style: none; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,.08); overflow: hidden; }
.todo-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid #f0f0f0;
transition: opacity 0.2s;
}
.todo-item:last-child { border-bottom: none; }
.todo-item.completed .todo-text { text-decoration: line-through; color: #aaa; }
.todo-item.removing { opacity: 0; }
.todo-check { width: 1.2rem; height: 1.2rem; cursor: pointer; accent-color: #4a90e2; }
.todo-text { flex: 1; }
.delete-btn { background: none; border: none; color: #ddd; cursor: pointer; font-size: 1rem; padding: 0.2rem; }
.delete-btn:hover { color: #e74c3c; }
.edit-input {
flex: 1;
border: none;
outline: 2px solid #4a90e2;
border-radius: 4px;
padding: 2px 6px;
font-size: 1rem;
}
.footer { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 0; color: #888; font-size: 0.9rem; }
#clear-completed { background: none; border: none; color: #888; cursor: pointer; }
#clear-completed:hover { text-decoration: underline; }
This project demonstrates the complete vanilla JavaScript skillset. The same patterns — event delegation, state management, local persistence, optimistic updates — are the foundation of what React and Vue do internally.
Next project: Weather App with API — fetching live data and building a dynamic UI.
📱
Get Notes Free →Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises