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.
| Feature | Technique |
|---|---|
| Add todo | Push to array, re-render |
| Mark complete | Toggle done boolean with .map() |
| Delete todo | Filter out by ID with .filter() |
| Filter view | Filter array before rendering |
| Item count | Filter for incomplete, display length |
| Persistence | JSON stringify/parse to localStorage |
| Clear completed | Filter out done todos |
HTML Structure
/* 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.
// 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.
// 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
// 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();
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.
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.
CSS Highlights
/* 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:
- Drag-and-drop reordering β use the HTML Drag and Drop API to let users reorder todos
- Priority levels β add a low/medium/high priority badge to each todo
- Due dates β add a date picker and highlight overdue todos in red
- Search β live-filter todos by text as the user types in a search box
- 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 persistencerender.jsβ all DOM rendering functionsevents.jsβ all event listener setupmain.jsβ import all modules and initialise
This introduces you to module design thinking used in production JavaScript.
Concepts Practiced
| Concept | Where Used |
|---|---|
| DOM manipulation | innerHTML rendering, querying elements |
| Event delegation | Single listener on #todo-list |
| Array methods | .map(), .filter(), .push() |
| localStorage | JSON save/load on every state mutation |
| Closures | State variables captured by event handlers |
| contentEditable | Inline text editing without input fields |
| CSS transitions | Removal 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
todosarray +filterstring - 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