Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
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 this course's notes on Telegram!

Free cheat sheets, summaries & practice exercises

Get Notes Free →
!