Build a To-Do App with JavaScript
A to-do app looks simple at first glance. You type a task, click a button, and the item appears in a list. But behind that tiny interaction is a very useful set of front-end skills: DOM manipulation, event handling, state management, local storage, rendering logic, and clean user experience design.
That is exactly why a to-do app is one of the best beginner projects in JavaScript.
It teaches you how to think like a developer, not just how to write code. You start with a blank page, then gradually give it life. By the end, you will have something that feels real and useful, not just a tutorial toy.
In this article, we will build a complete to-do app step by step using vanilla JavaScript. No frameworks. No complicated setup. Just HTML, CSS, and JavaScript working together in a practical way.
We will build features such as adding tasks, marking them as completed, deleting tasks, editing tasks, filtering tasks, clearing completed tasks, and saving everything in the browser so the data remains after refresh.
What We Are Building
Our final app will include:
Adding new tasks
Displaying all tasks
Marking tasks as done
Editing task text
Deleting a task
Filtering by all, active, and completed
Clearing completed tasks
Saving tasks in local storage
A clean and responsive interface
This project is small enough to finish in one sitting, but rich enough to teach important JavaScript concepts that show up again and again in real projects.
Why Build a To-Do App?
A to-do app is a classic beginner project, but it is not boring. It is practical.
Here is why it matters:
It helps you understand how data is stored and updated in the browser.
It teaches you how to connect the user interface with JavaScript logic.
It gives you practice with arrays, objects, and functions.
It introduces event-driven programming in a very natural way.
It is easy to improve later with new features.
The best part is that you can keep extending it. Once the basic version is working, you can add due dates, priority levels, categories, drag-and-drop ordering, dark mode, reminders, or even sync with a backend API.
Project Structure
We will keep the project simple with three files:
todo-app/
│
├── index.html
├── style.css
└── script.js
This is perfect for learning because each file has a clear role:
index.htmlholds the structurestyle.csshandles the designscript.jshandles the behavior
Step 1: Create the HTML Structure
Let us begin with the HTML. We need a form for adding tasks, a list to display them, and controls for filtering and clearing tasks.
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>To-Do App</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app-container">
<h1>To-Do App</h1>
<form id="todo-form">
<input
type="text"
id="todo-input"
placeholder="Add a new task..."
autocomplete="off"
/>
<button type="submit">Add</button>
</form>
<div class="controls">
<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>
<button id="clear-completed">Clear Completed</button>
</div>
<ul id="todo-list"></ul>
<p id="task-count"></p>
</div>
<script src="script.js"></script>
</body>
</html>
What this HTML does
We created:
A wrapper container for the app
A title
A form with an input and button
Filter buttons
A clear completed button
An unordered list for tasks
A paragraph for showing task count
This is already enough to start building the logic in JavaScript.
Step 2: Add the CSS Styling
A to-do app does not need to be fancy, but it should be pleasant to use. Good design makes the app feel complete and easier to interact with.
style.css
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Arial, sans-serif;
background: linear-gradient(135deg, #f5f7fa, #c3cfe2);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.app-container {
background: white;
width: 100%;
max-width: 600px;
border-radius: 16px;
padding: 24px;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1);
}
h1 {
text-align: center;
margin-top: 0;
color: #222;
}
form {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
#todo-input {
flex: 1;
padding: 12px 14px;
border: 1px solid #ccc;
border-radius: 10px;
font-size: 16px;
}
button {
padding: 12px 16px;
border: none;
border-radius: 10px;
cursor: pointer;
font-size: 14px;
background: #4f46e5;
color: white;
}
button:hover {
opacity: 0.9;
}
.controls {
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.filter-btn {
background: #e5e7eb;
color: #111827;
}
.filter-btn.active {
background: #111827;
color: white;
}
#clear-completed {
background: #ef4444;
}
#todo-list {
list-style: none;
padding: 0;
margin: 0;
}
.todo-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px;
border: 1px solid #e5e7eb;
border-radius: 12px;
margin-bottom: 10px;
background: #fafafa;
}
.todo-text {
flex: 1;
margin-right: 12px;
word-break: break-word;
}
.todo-item.completed .todo-text {
text-decoration: line-through;
color: #6b7280;
}
.todo-actions {
display: flex;
gap: 8px;
}
.todo-actions button {
padding: 8px 10px;
font-size: 13px;
}
.edit-btn {
background: #f59e0b;
}
.delete-btn {
background: #dc2626;
}
.complete-btn {
background: #10b981;
}
#task-count {
margin-top: 16px;
text-align: center;
color: #374151;
font-size: 14px;
}
input:focus,
button:focus {
outline: 2px solid #93c5fd;
outline-offset: 2px;
}
@media (max-width: 600px) {
form {
flex-direction: column;
}
.controls {
flex-direction: column;
}
.filters {
width: 100%;
}
.filters button,
#clear-completed {
width: 100%;
}
}
Why this styling works
The CSS keeps the app clean and readable:
A centered card layout
A soft background gradient
Clear button colors
Mobile-friendly responsiveness
Completed tasks appear crossed out
Buttons are visually distinct
This is enough to make the project feel polished without being over-designed.
Step 3: Understand the Data Model
Before writing JavaScript, it helps to decide how tasks will be stored.
Each task can be represented as an object like this:
{
id: 123456789,
text: "Learn JavaScript",
completed: false
}
Why use objects?
Because tasks have more than one piece of data:
the text
whether the task is completed
a unique identifier
We will keep all tasks inside an array:
let todos = [];
That array will act as our in-memory database.
Step 4: Start the JavaScript File
script.js
Let us begin by selecting the DOM elements we need.
const todoForm = document.getElementById("todo-form");
const todoInput = document.getElementById("todo-input");
const todoList = document.getElementById("todo-list");
const taskCount = document.getElementById("task-count");
const clearCompletedBtn = document.getElementById("clear-completed");
const filterButtons = document.querySelectorAll(".filter-btn");
Now create the main variables:
let todos = [];
let currentFilter = "all";
We now have:
the form
the input
the task list container
the buttons
an array for tasks
a filter state
Step 5: Load Tasks from Local Storage
Local storage lets us save data in the browser. That way, tasks stay even after a page refresh.
Save data
function saveTodos() {
localStorage.setItem("todos", JSON.stringify(todos));
}
Load data
function loadTodos() {
const storedTodos = localStorage.getItem("todos");
if (storedTodos) {
todos = JSON.parse(storedTodos);
}
}
We will call loadTodos() when the app starts.
loadTodos();
This is one of the most useful lessons in JavaScript: when data changes, you can store it and recover it later.
Step 6: Render the Task List
Now we need a function that shows tasks on the screen.
Render function
function renderTodos() {
todoList.innerHTML = "";
const filteredTodos = todos.filter((todo) => {
if (currentFilter === "active") return !todo.completed;
if (currentFilter === "completed") return todo.completed;
return true;
});
filteredTodos.forEach((todo) => {
const li = document.createElement("li");
li.className = `todo-item ${todo.completed ? "completed" : ""}`;
li.dataset.id = todo.id;
li.innerHTML = `
<span class="todo-text">${todo.text}</span>
<div class="todo-actions">
<button class="complete-btn">${todo.completed ? "Undo" : "Done"}</button>
<button class="edit-btn">Edit</button>
<button class="delete-btn">Delete</button>
</div>
`;
todoList.appendChild(li);
});
updateTaskCount();
}
What this function does
Clears the existing list
Filters tasks based on selected filter
Creates a list item for each task
Adds buttons for completing, editing, and deleting
Updates the task count
This is the core of the app.
Whenever something changes, we call renderTodos() again.
Step 7: Add a New Task
Now let us handle the form submission.
todoForm.addEventListener("submit", function (e) {
e.preventDefault();
const taskText = todoInput.value.trim();
if (taskText === "") {
alert("Please enter a task.");
return;
}
const newTodo = {
id: Date.now(),
text: taskText,
completed: false,
};
todos.push(newTodo);
todoInput.value = "";
saveTodos();
renderTodos();
});
How this works
Prevent the form from refreshing the page
Read the input value
Remove extra spaces
Check whether the task is empty
Create a new task object
Add it to the array
Save and re-render
This is one of those places where JavaScript feels instantly rewarding. One small submission becomes visible immediately.
Step 8: Handle Task Actions with Event Delegation
Each task has buttons. Instead of attaching listeners to every button one by one, we can use event delegation on the parent list.
This is cleaner and more efficient.
todoList.addEventListener("click", function (e) {
const target = e.target;
const todoItem = target.closest(".todo-item");
if (!todoItem) return;
const id = Number(todoItem.dataset.id);
if (target.classList.contains("complete-btn")) {
toggleTodo(id);
}
if (target.classList.contains("delete-btn")) {
deleteTodo(id);
}
if (target.classList.contains("edit-btn")) {
editTodo(id);
}
});
Why event delegation is useful
Instead of adding three listeners for every task, we add one listener to the parent <ul>. When a button is clicked, we detect which button it was.
This is a real-world technique you will use often.
Step 9: Toggle Completion Status
Let us add the function for marking tasks done or undoing them.
function toggleTodo(id) {
todos = todos.map((todo) => {
if (todo.id === id) {
return { ...todo, completed: !todo.completed };
}
return todo;
});
saveTodos();
renderTodos();
}
Explanation
This function loops through the array and updates only the matching task.
If the task matches the ID, reverse its
completedvalueOtherwise, keep it the same
We use .map() because it returns a new array, which is a clean and predictable way to update state.
Step 10: Delete a Task
Now let us remove tasks completely.
function deleteTodo(id) {
todos = todos.filter((todo) => todo.id !== id);
saveTodos();
renderTodos();
}
This is simple and elegant:
Keep every task except the one we want to delete
Save the updated list
Re-render the UI
Step 11: Edit a Task
Editing is a very useful feature. People make mistakes, and tasks change.
Here is one simple way to do it using prompt().
function editTodo(id) {
const todo = todos.find((todo) => todo.id === id);
if (!todo) return;
const newText = prompt("Edit your task:", todo.text);
if (newText === null) return;
const trimmedText = newText.trim();
if (trimmedText === "") {
alert("Task cannot be empty.");
return;
}
todo.text = trimmedText;
saveTodos();
renderTodos();
}
Why this is okay for a beginner project
prompt() is not the most beautiful editing experience, but it keeps the project simple and focused on JavaScript logic.
Later, you can replace it with an inline editable input.
Step 12: Add Filtering
We already created filter buttons in HTML. Now we need to make them work.
filterButtons.forEach((button) => {
button.addEventListener("click", function () {
filterButtons.forEach((btn) => btn.classList.remove("active"));
this.classList.add("active");
currentFilter = this.dataset.filter;
renderTodos();
});
});
What the filters do
allshows everythingactiveshows tasks that are not completedcompletedshows tasks that are completed
Filtering is one of the best examples of separating data from presentation. The array stays the same, but the display changes.
Step 13: Clear Completed Tasks
Now let us add a button that removes all completed tasks at once.
clearCompletedBtn.addEventListener("click", function () {
todos = todos.filter((todo) => !todo.completed);
saveTodos();
renderTodos();
});
This is helpful when the list starts to grow. It lets the user clean up finished work quickly.
Step 14: Show Task Count
A small detail like task count makes the app feel more complete.
function updateTaskCount() {
const activeTasks = todos.filter((todo) => !todo.completed).length;
taskCount.textContent = `${activeTasks} task${activeTasks !== 1 ? "s" : ""} left`;
}
If there is one task left, it shows:
1 task left
If there are many:
5 tasks left
This tiny detail adds a human touch to the interface.
Step 15: Put Everything Together
Here is the full JavaScript file in one place.
script.js
const todoForm = document.getElementById("todo-form");
const todoInput = document.getElementById("todo-input");
const todoList = document.getElementById("todo-list");
const taskCount = document.getElementById("task-count");
const clearCompletedBtn = document.getElementById("clear-completed");
const filterButtons = document.querySelectorAll(".filter-btn");
let todos = [];
let currentFilter = "all";
function saveTodos() {
localStorage.setItem("todos", JSON.stringify(todos));
}
function loadTodos() {
const storedTodos = localStorage.getItem("todos");
if (storedTodos) {
todos = JSON.parse(storedTodos);
}
}
function updateTaskCount() {
const activeTasks = todos.filter((todo) => !todo.completed).length;
taskCount.textContent = `${activeTasks} task${activeTasks !== 1 ? "s" : ""} left`;
}
function renderTodos() {
todoList.innerHTML = "";
const filteredTodos = todos.filter((todo) => {
if (currentFilter === "active") return !todo.completed;
if (currentFilter === "completed") return todo.completed;
return true;
});
filteredTodos.forEach((todo) => {
const li = document.createElement("li");
li.className = `todo-item ${todo.completed ? "completed" : ""}`;
li.dataset.id = todo.id;
li.innerHTML = `
<span class="todo-text">${todo.text}</span>
<div class="todo-actions">
<button class="complete-btn">${todo.completed ? "Undo" : "Done"}</button>
<button class="edit-btn">Edit</button>
<button class="delete-btn">Delete</button>
</div>
`;
todoList.appendChild(li);
});
updateTaskCount();
}
function toggleTodo(id) {
todos = todos.map((todo) => {
if (todo.id === id) {
return { ...todo, completed: !todo.completed };
}
return todo;
});
saveTodos();
renderTodos();
}
function deleteTodo(id) {
todos = todos.filter((todo) => todo.id !== id);
saveTodos();
renderTodos();
}
function editTodo(id) {
const todo = todos.find((todo) => todo.id === id);
if (!todo) return;
const newText = prompt("Edit your task:", todo.text);
if (newText === null) return;
const trimmedText = newText.trim();
if (trimmedText === "") {
alert("Task cannot be empty.");
return;
}
todo.text = trimmedText;
saveTodos();
renderTodos();
}
todoForm.addEventListener("submit", function (e) {
e.preventDefault();
const taskText = todoInput.value.trim();
if (taskText === "") {
alert("Please enter a task.");
return;
}
const newTodo = {
id: Date.now(),
text: taskText,
completed: false,
};
todos.push(newTodo);
todoInput.value = "";
saveTodos();
renderTodos();
});
todoList.addEventListener("click", function (e) {
const target = e.target;
const todoItem = target.closest(".todo-item");
if (!todoItem) return;
const id = Number(todoItem.dataset.id);
if (target.classList.contains("complete-btn")) {
toggleTodo(id);
}
if (target.classList.contains("delete-btn")) {
deleteTodo(id);
}
if (target.classList.contains("edit-btn")) {
editTodo(id);
}
});
filterButtons.forEach((button) => {
button.addEventListener("click", function () {
filterButtons.forEach((btn) => btn.classList.remove("active"));
this.classList.add("active");
currentFilter = this.dataset.filter;
renderTodos();
});
});
clearCompletedBtn.addEventListener("click", function () {
todos = todos.filter((todo) => !todo.completed);
saveTodos();
renderTodos();
});
loadTodos();
renderTodos();
Step 16: How the App Works Under the Hood
Now that the app is built, let us slow down and look at the logic in a more thoughtful way.
1. State lives in an array
The todos array is the source of truth. Every task is stored there.
2. The UI is re-rendered from that array
Instead of manually changing the DOM in many places, we rebuild the list from the current data whenever something changes.
3. Local storage keeps the state persistent
Whenever the array changes, we save it to the browser.
4. Filters do not change the data
They only change which tasks are shown.
This separation is important. It keeps the code manageable as the app grows.
Step 17: Common Mistakes Beginners Make
When building a to-do app, people often run into the same issues. It is useful to know them early.
Forgetting preventDefault()
If you do not stop the form from submitting normally, the page refreshes.
e.preventDefault();
Not trimming input values
Users may accidentally type spaces before or after the task.
const taskText = todoInput.value.trim();
Using duplicate IDs
If you use a task name as the ID, you may run into problems when tasks repeat. A unique ID from Date.now() is a simple solution.
Not re-rendering after updates
If you change the array but forget to call renderTodos(), the screen will not reflect the new state.
Forgetting to save to local storage
If you do not call saveTodos(), the tasks disappear when the page reloads.
These small details matter a lot.
Step 18: Improving the User Experience
Now that the basic app works, let us think like a real product builder.
A good app is not just functional. It should feel smooth.
Add keyboard support
A user should be able to press Enter in the input and add a task. The form already handles this automatically.
Add empty-state feedback
When there are no tasks, show a friendly message.
function renderTodos() {
todoList.innerHTML = "";
const filteredTodos = todos.filter((todo) => {
if (currentFilter === "active") return !todo.completed;
if (currentFilter === "completed") return todo.completed;
return true;
});
if (filteredTodos.length === 0) {
todoList.innerHTML = `<li class="todo-item">No tasks to show.</li>`;
updateTaskCount();
return;
}
filteredTodos.forEach((todo) => {
const li = document.createElement("li");
li.className = `todo-item ${todo.completed ? "completed" : ""}`;
li.dataset.id = todo.id;
li.innerHTML = `
<span class="todo-text">${todo.text}</span>
<div class="todo-actions">
<button class="complete-btn">${todo.completed ? "Undo" : "Done"}</button>
<button class="edit-btn">Edit</button>
<button class="delete-btn">Delete</button>
</div>
`;
todoList.appendChild(li);
});
updateTaskCount();
}
Add confirmation before deleting
For a safer experience, ask before removing a task.
function deleteTodo(id) {
const confirmDelete = confirm("Are you sure you want to delete this task?");
if (!confirmDelete) return;
todos = todos.filter((todo) => todo.id !== id);
saveTodos();
renderTodos();
}
Improve editing
Later, you can replace prompt() with a custom modal or inline input box.
Step 19: A More Advanced Version with Inline Editing
If you want the app to feel more modern, you can make the editing happen directly in the list.
This is more advanced, but it is a great next step.
Instead of this:
const newText = prompt("Edit your task:", todo.text);
You could create an input field inside the task item and save changes on blur or Enter.
That approach gives users a smoother experience, but it requires a little more DOM work. It is a perfect upgrade once you are comfortable with the simple version.
Step 20: Ideas for Extra Features
Once the app works, you can keep improving it. This is where the project becomes yours.
Here are some possible upgrades:
1. Search tasks
Add a search bar that filters tasks by keyword.
2. Drag and drop sorting
Let the user reorder tasks visually.
3. Due dates
Allow tasks to include deadlines.
4. Priority levels
Mark tasks as low, medium, or high priority.
5. Dark mode
Add a theme toggle for light and dark appearance.
6. Categories or tags
Organize tasks by work, personal, study, and so on.
7. Animations
Add smooth transitions when adding or deleting tasks.
8. Backend sync
Store tasks in a database instead of local storage.
These extensions are excellent practice because they build on the same foundation.
Step 21: Turning This Into a Real-World App
A real to-do app in production often needs more than browser storage.
You might eventually want:
user accounts
cloud sync
notifications
reminders
recurring tasks
offline support
export/import features
That is where the project starts to feel less like a tutorial and more like a product.
Even then, the same basic structure remains:
keep state in data
render from state
respond to user events
save changes somewhere persistent
That pattern appears in almost every front-end app.
Step 22: Why This Project Teaches Good JavaScript Habits
This project is small, but it teaches habits that matter a lot.
Clean separation of concerns
HTML for structure, CSS for appearance, JavaScript for behavior.
Reusable functions
We created separate functions for saving, loading, rendering, editing, and deleting.
Working with arrays
You used push, filter, map, find, and forEach.
DOM manipulation
You learned how to create elements, update text, and attach events.
State-driven UI
The UI comes from the data, not the other way around.
That is a very important mindset.
Step 23: Final Complete Thought
The nice thing about a to-do app is that it rewards every small improvement.
At first, it is just a list.
Then it becomes interactive.
Then it becomes persistent.
Then it becomes useful.
And somewhere in that process, something clicks. You stop feeling like you are just copying code and start feeling like you are building something with intention.
That moment matters.
A project like this can be a tiny bridge between “I am learning JavaScript” and “I can build something real with JavaScript.”
And honestly, that is a beautiful place to be.
Full Summary
We built a complete to-do app using plain JavaScript with:
HTML for structure
CSS for styling
JavaScript for behavior
local storage for persistence
event delegation for efficient interaction
filtering for task organization
editing, deleting, and completion toggles for full functionality
This is one of the best beginner projects because it teaches the fundamentals in a way that feels practical and satisfying.
Final Code Checklist
Before you move on, make sure your app includes:
A form to add tasks
A list to show tasks
Buttons for complete, edit, and delete
Filter buttons
A clear completed option
Task count
local storage saving and loading
If all of those pieces are working, congratulations. You have built a real JavaScript application.
Conclusion
Building a to-do app step by step is more than a beginner exercise. It is a training ground for real JavaScript thinking.
You learn how to manage state, respond to user input, update the DOM, and persist data. These are the exact building blocks behind much larger applications.
The first version does not need to be perfect. It just needs to work. After that, you can improve the design, refine the experience, and add features that make it feel personal.
That is the beauty of projects like this. They begin simply, but they can grow with you.