Build a To-Do App with AI: Your First Dynamic Web App
Your portfolio and landing page were static. This is your first step into dynamic, interactive JavaScript — where user actions change what's on the screen. You'll build a to-do app that adds, completes, deletes, and saves tasks — all with AI writing the code.
What You'll Build
A fully interactive to-do app: add tasks with a form, mark them complete (strikethrough), delete them, filter by status (all/active/completed), and save everything to localStorage so tasks survive a page refresh. Stack: HTML + CSS + vanilla JavaScript. No framework, no backend, no database. Time: ~1 hour.
Why a To-Do App Is the Perfect Third Project
Your portfolio taught you HTML structure and CSS styling. Your landing page taught you conversion design and form handling. This to-do app teaches you the fundamentals that power every dynamic web application:
- DOM manipulation: Creating and removing HTML elements with JavaScript
- Event listeners: Responding to clicks, form submissions, and keyboard input
- State management: Keeping a JavaScript array in sync with what the user sees
- Data persistence: Saving and loading data from localStorage
These exact patterns — in more complex form — are what React, Vue, and every frontend framework are built on. Understanding them in plain JavaScript first makes frameworks 10x easier to learn.
Step 1: Build the HTML Structure
"Create index.html for a to-do app. Include: (1) a header with the app name 'TaskFlow', (2) an input form with a text field and 'Add Task' button, (3) filter buttons: All, Active, Completed, (4) the task list container (empty — JavaScript will fill it), (5) a footer showing 'X items left'. Use semantic HTML. The task list should be a ul with id='task-list'. Each task li will have: a checkbox, the task text, and a delete button."
Key HTML Pattern
<!-- The form — this is where new tasks are entered -->
<form id="task-form">
<input
type="text"
id="task-input"
placeholder="What needs to be done?"
autofocus
required
>
<button type="submit">Add Task</button>
</form>
<!-- Filter buttons -->
<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>
<!-- Task list — JavaScript populates this -->
<ul id="task-list"></ul>
<!-- Footer -->
<footer class="app-footer">
<span id="items-left">0 items left</span>
<button id="clear-completed">Clear Completed</button>
</footer>
Step 2: Write the JavaScript
"Write js/main.js for my to-do app. Requirements: (1) Store tasks as an array of objects: { id, text, completed }. (2) Save to localStorage after every change. (3) Load from localStorage on page load. (4) Add task: on form submit, create a new task object, add to array, render it. (5) Toggle complete: clicking the checkbox toggles completed state and adds strikethrough style. (6) Delete task: clicking the X button removes it. (7) Filter: All shows everything, Active shows uncompleted, Completed shows completed. (8) Counter: show number of active (uncompleted) tasks. (9) Clear completed: remove all completed tasks. Use event delegation on the task list (one listener for all tasks, not one per task)."
The Core JavaScript (What AI Generates)
// js/main.js
// Tested with vanilla JavaScript, no frameworks
// ===== STATE =====
// Load tasks from localStorage on startup, or start with empty array
let todos = JSON.parse(localStorage.getItem('todos')) || []
let currentFilter = 'all'
// ===== DOM REFERENCES =====
const form = document.getElementById('task-form')
const input = document.getElementById('task-input')
const taskList = document.getElementById('task-list')
const itemsLeft = document.getElementById('items-left')
const filterButtons = document.querySelectorAll('.filter-btn')
// ===== RENDER =====
// This function is the heart of the app — it turns the todos array into HTML
function render() {
// Filter todos based on current filter
const filtered = todos.filter(todo => {
if (currentFilter === 'active') return !todo.completed
if (currentFilter === 'completed') return todo.completed
return true // 'all'
})
// Build the HTML for each task
taskList.innerHTML = filtered.map(todo => `
<li class="task-item ${todo.completed ? 'completed' : ''}" data-id="${todo.id}">
<input type="checkbox" class="task-checkbox" ${todo.completed ? 'checked' : ''}>
<span class="task-text">${escapeHTML(todo.text)}</span>
<button class="task-delete" aria-label="Delete task">✕</button>
</li>
`).join('')
// Update counter
const activeCount = todos.filter(t => !t.completed).length
itemsLeft.textContent = `${activeCount} item${activeCount !== 1 ? 's' : ''} left`
// Save to localStorage
localStorage.setItem('todos', JSON.stringify(todos))
}
// ===== EVENT HANDLERS =====
// Add new task
form.addEventListener('submit', (e) => {
e.preventDefault()
const text = input.value.trim()
if (!text) return
todos.push({
id: Date.now(), // Simple unique ID
text,
completed: false
})
input.value = '' // Clear input
render() // Re-render the list
})
// Toggle complete and delete — using EVENT DELEGATION
// One listener on the parent list handles clicks on any child element
taskList.addEventListener('click', (e) => {
const taskItem = e.target.closest('.task-item')
if (!taskItem) return
const id = Number(taskItem.dataset.id)
// Clicked the checkbox → toggle completed
if (e.target.classList.contains('task-checkbox')) {
const todo = todos.find(t => t.id === id)
if (todo) todo.completed = !todo.completed
render()
}
// Clicked the delete button → remove task
if (e.target.classList.contains('task-delete')) {
todos = todos.filter(t => t.id !== id)
render()
}
})
// Filter buttons
filterButtons.forEach(btn => {
btn.addEventListener('click', () => {
currentFilter = btn.dataset.filter
filterButtons.forEach(b => b.classList.remove('active'))
btn.classList.add('active')
render()
})
})
// Clear completed
document.getElementById('clear-completed')?.addEventListener('click', () => {
todos = todos.filter(t => !t.completed)
render()
})
// ===== UTILITY =====
function escapeHTML(str) {
const div = document.createElement('div')
div.textContent = str
return div.innerHTML
}
// ===== INITIAL RENDER =====
render()
Understanding the Key Patterns
The Render Pattern
The render() function is the most important concept. It takes the JavaScript state (the todos array) and transforms it into visible HTML. Every time data changes → call render() → the screen updates. This "data drives the UI" pattern is exactly what React's useState and re-rendering do automatically.
Event Delegation
Instead of attaching a listener to every checkbox and every delete button (which breaks when new tasks are added), we attach one listener to the parent <ul> and check what was actually clicked. This is called event delegation — the event bubbles up from the child to the parent, and we handle it there.
localStorage: Survival Across Refreshes
Without localStorage, your tasks disappear when you refresh the page — they only exist in JavaScript memory. localStorage.setItem() saves a string to the browser's permanent storage. JSON.stringify() converts your array to a string; JSON.parse() converts it back. This is the simplest form of database persistence — stored in the user's browser.
Escaping User Input
The escapeHTML() function prevents XSS attacks. Without it, a user could type <script>alert('hacked')</script> as a task and it would execute as real JavaScript. Escaping converts HTML characters to their safe text equivalents.
Step 3: Style It
"Write css/styles.css for my to-do app. Design: dark theme (#0A0E1A background), centered card (max-width 500px), rounded input with accent-colored submit button, task items with checkbox, text, and delete button on one row, completed tasks have line-through text and reduced opacity, filter buttons with active state, smooth transitions on task add/complete/delete. Make it look like a polished product, not a tutorial demo."
Step 4: Deploy
Same process as your portfolio: push to GitHub, connect to Vercel, instant deployment. Since this is a static site (localStorage is client-side), it runs perfectly on any static hosting.
git add .
git commit -m "To-do app complete — localStorage persistence"
git push origin main
Extend It (Bonus Challenges)
- Edit tasks: "Add the ability to double-click a task to edit its text inline. Show an input field, save on Enter or blur."
- Drag to reorder: "Add drag-and-drop reordering of tasks. Use the HTML5 Drag and Drop API, no libraries."
- Due dates: "Add an optional due date to each task. Show overdue tasks in red."
- Move to a real backend: "Replace localStorage with a Supabase database so tasks sync across devices." (This is a big step — takes your to-do from local to cloud.)
What to Learn Next
Frequently Asked Questions
What will I learn from building a to-do app?
DOM manipulation (adding/removing elements dynamically), event listeners (handling clicks and form submissions), data persistence (saving to localStorage so tasks survive refresh), and the render pattern (data drives UI). These are the exact patterns used in every interactive web app — and the foundation that frameworks like React build on.
Should I use React or plain JavaScript for a to-do app?
Start with plain JavaScript. It teaches you what React does under the hood — DOM updates, event handling, state management — without framework complexity. Once you understand these fundamentals by building them yourself, React's component model and useState will make much more sense. This tutorial uses plain HTML/CSS/JS.
What is localStorage?
localStorage is a browser API that stores key-value pairs persistently — data survives page refresh and browser close. It stores strings only (use JSON.stringify/JSON.parse for objects). Each domain gets ~5MB. Perfect for small apps like to-do lists. Not for sensitive data (not encrypted) or large datasets (use a database for that).
How do I make tasks persist after page refresh?
Save your todos array to localStorage after every change: localStorage.setItem('todos', JSON.stringify(todos)). On page load, read it back: JSON.parse(localStorage.getItem('todos')) || []. The || [] gives you an empty array if nothing was saved yet.