Build a To-Do App with JavaScript Step by Step

Build a To-Do App with JavaScript Step by Step

Building a to-do app is one of the best beginner-friendly projects in JavaScript because it teaches you a little bit of everything at once: DOM manipulation, event handling, state management, local storage, form validation, and clean UI structure. It is simple enough to finish, but real enough to feel useful.

That is what makes it such a good project.

A to-do app is also one of those projects that you can keep improving forever. At first, it only needs to add and remove tasks. Then you want to mark tasks as done. Then you want filters. Then dark mode. Then persistence. Then edit mode. Then drag and drop. Before you know it, you have built something that feels like a real product instead of just a classroom exercise.

In this article, we will build a complete to-do app with plain JavaScript, HTML, and CSS. We will go step by step and keep the code readable, practical, and easy to reuse. By the end, you will have a clean app that lets users:

  • Add tasks

  • Mark tasks as completed

  • Edit tasks

  • Delete tasks

  • Filter tasks

  • Save tasks in local storage

  • Clear completed tasks

  • Keep the UI responsive and pleasant to use

I will also explain the logic behind each step so you do not just copy the code, but actually understand how it works.


What we are building

Before writing code, it helps to picture the final app.

Our to-do app will include:

  • A form to add new tasks

  • A task list

  • Checkboxes or buttons to mark tasks as completed

  • Edit and delete controls

  • A filter area for all, active, and completed tasks

  • A button to clear completed tasks

  • Persistence using localStorage

The design will stay simple and modern. We are not trying to create a huge application framework here. We are trying to build something that is elegant, practical, and easy to understand.


Project structure

A clean structure makes the project much easier to maintain.

Here is a simple setup:

todo-app/
│
├── index.html
├── style.css
└── app.js

That is enough for this project.

  • index.html contains the HTML structure

  • style.css contains the design

  • app.js contains all the JavaScript logic

Keeping things separated like this is a good habit. It helps you focus on one part at a time and makes the project easier to debug later.


Step 1: Create the HTML layout

Let’s start with the HTML.

We need a container, a title, a form, a list, and some action buttons.

<!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>
  <main class="app">
    <section class="todo-card">
      <h1>My To-Do List</h1>

      <form id="todo-form" class="todo-form">
        <input 
          type="text" 
          id="todo-input" 
          placeholder="Add a new task..." 
          autocomplete="off"
        />
        <button type="submit">Add</button>
      </form>

      <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" class="todo-list"></ul>

      <div class="footer-actions">
        <span id="task-count">0 tasks left</span>
        <button id="clear-completed">Clear Completed</button>
      </div>
    </section>
  </main>

  <script src="app.js"></script>
</body>
</html>

At this point, the page does not do anything yet. But the structure is ready.

Let’s break it down a little:

  • The <form> handles task input.

  • The <ul> will hold the list items.

  • The filter buttons let us switch between views.

  • The footer shows a task count and gives us a way to clear completed tasks.

A small note: using a form for the input is better than a button plus input combination because it gives you natural Enter-key support.

That tiny detail makes the app feel better immediately.


Step 2: Add basic styling

A good to-do app does not need flashy design. It just needs spacing, readable text, clear buttons, and a structure that feels comfortable.

Here is a complete CSS file to get us started:

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, Helvetica, sans-serif;
  background: linear-gradient(135deg, #f3f4f6, #e5e7eb);
  color: #111827;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
}

.app {
  width: 100%;
  max-width: 600px;
}

.todo-card {
  background: #ffffff;
  border-radius: 16px;
  padding: 24px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
}

h1 {
  margin-top: 0;
  margin-bottom: 20px;
  text-align: center;
}

.todo-form {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

.todo-form input {
  flex: 1;
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 10px;
  font-size: 16px;
  outline: none;
}

.todo-form input:focus {
  border-color: #2563eb;
}

.todo-form button,
.filter-btn,
#clear-completed {
  border: none;
  border-radius: 10px;
  padding: 12px 16px;
  cursor: pointer;
  font-size: 14px;
}

.todo-form button {
  background: #2563eb;
  color: white;
}

.todo-form button:hover {
  background: #1d4ed8;
}

.filters {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
  flex-wrap: wrap;
}

.filter-btn {
  background: #e5e7eb;
  color: #111827;
}

.filter-btn.active {
  background: #111827;
  color: white;
}

.todo-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.todo-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  background: #f9fafb;
  border: 1px solid #e5e7eb;
  padding: 14px 16px;
  border-radius: 12px;
  margin-bottom: 12px;
}

.todo-content {
  display: flex;
  align-items: center;
  gap: 10px;
  flex: 1;
}

.todo-text.completed {
  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;
  color: white;
}

.delete-btn {
  background: #ef4444;
  color: white;
}

.footer-actions {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-top: 20px;
  gap: 10px;
  flex-wrap: wrap;
}

#clear-completed {
  background: #6b7280;
  color: white;
}

#clear-completed:hover {
  background: #4b5563;
}

.empty-state {
  text-align: center;
  color: #6b7280;
  padding: 20px;
}

This gives us a clean interface that already feels usable. The empty state message is especially helpful because an app that shows nothing can feel broken. A gentle message makes it feel intentional.


Step 3: Plan the JavaScript logic

Before coding the behavior, let’s think about the app’s state.

We need to store tasks in memory, and also save them in the browser so they remain after refresh.

A task can be represented like this:

{
  id: 123456789,
  text: "Learn JavaScript",
  completed: false
}

Each task needs:

  • a unique id

  • the task text

  • a completed status

That is enough for the app we are building.

We will also need a variable to store the currently selected filter:

let currentFilter = "all";

The filter can be one of:

  • "all"

  • "active"

  • "completed"

Now let’s move to the actual JavaScript file.


Step 4: Select the DOM elements

First, connect JavaScript to the HTML.

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");

These references let us interact with the form, input, list, count, and buttons.

We also need a variable for the task data:

let todos = JSON.parse(localStorage.getItem("todos")) || [];
let currentFilter = "all";

This line is important.

localStorage.getItem("todos") returns saved data as a string. We convert it back into JavaScript objects with JSON.parse(). If there is no saved data yet, we use an empty array.

That means the app can remember tasks after the page is refreshed.


Step 5: Create the render function

Rendering means turning the task data into HTML on the screen.

Let’s create a function that displays the tasks:

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="empty-state">No tasks found.</li>`;
    updateTaskCount();
    return;
  }

  filteredTodos.forEach((todo) => {
    const li = document.createElement("li");
    li.className = "todo-item";
    li.dataset.id = todo.id;

    li.innerHTML = `
      <div class="todo-content">
        <input type="checkbox" class="toggle-todo" ${todo.completed ? "checked" : ""} />
        <span class="todo-text ${todo.completed ? "completed" : ""}">${todo.text}</span>
      </div>
      <div class="todo-actions">
        <button class="edit-btn">Edit</button>
        <button class="delete-btn">Delete</button>
      </div>
    `;

    todoList.appendChild(li);
  });

  updateTaskCount();
}

This function does several things:

  1. Clears the current list

  2. Filters tasks according to the selected filter

  3. Shows an empty state when there are no matching tasks

  4. Creates HTML for each task

  5. Adds tasks to the DOM

  6. Updates the counter

This is the heart of the app.

A very common mistake in small JavaScript projects is trying to update the DOM in too many places. A cleaner approach is to keep one render function and call it whenever data changes. That keeps your app easier to reason about.


Step 6: Add tasks

Now let’s make the form work.

todoForm.addEventListener("submit", function (e) {
  e.preventDefault();

  const text = todoInput.value.trim();

  if (text === "") {
    alert("Please enter a task.");
    return;
  }

  const newTodo = {
    id: Date.now(),
    text: text,
    completed: false
  };

  todos.push(newTodo);
  saveTodos();
  renderTodos();

  todoInput.value = "";
  todoInput.focus();
});

Here is what happens:

  • The form submission is prevented from refreshing the page

  • The input text is trimmed to remove spaces

  • If the input is empty, we stop and show a warning

  • A new task object is created

  • The task is added to the array

  • The array is saved

  • The UI is refreshed

  • The input is cleared and focused again

That last line, todoInput.focus(), is one of those tiny touches that make the app feel friendly. It keeps the user in the flow.


Step 7: Save tasks in local storage

The app needs persistence.

Here is the save function:

function saveTodos() {
  localStorage.setItem("todos", JSON.stringify(todos));
}

That is all.

The todos array becomes a JSON string and gets stored in the browser.

This means:

  • Refreshing the page does not erase tasks

  • Closing the tab does not erase tasks

  • The app feels much more real

For a beginner project, this is a big win.


Step 8: Show the task count

Users like to see progress.

Let’s display how many active tasks remain:

function updateTaskCount() {
  const activeTasks = todos.filter((todo) => !todo.completed).length;
  taskCount.textContent = `${activeTasks} task${activeTasks !== 1 ? "s" : ""} left`;
}

This function counts only the tasks that are not completed.

It also handles singular and plural correctly:

  • 1 task left

  • 2 tasks left

These details matter more than they seem. Small language mistakes can make an app feel rough around the edges. Clean microcopy makes the app feel polished.


Step 9: Toggle task completion

Now we need to let users mark tasks as done.

We can use event delegation by listening for clicks on the entire list instead of adding many event listeners to each item individually.

That is a smart pattern because the task list is dynamic.

todoList.addEventListener("click", function (e) {
  const todoItem = e.target.closest(".todo-item");

  if (!todoItem) return;

  const id = Number(todoItem.dataset.id);

  if (e.target.classList.contains("delete-btn")) {
    deleteTodo(id);
  }

  if (e.target.classList.contains("edit-btn")) {
    editTodo(id);
  }
});

We also need to handle the checkbox change:

todoList.addEventListener("change", function (e) {
  if (!e.target.classList.contains("toggle-todo")) return;

  const todoItem = e.target.closest(".todo-item");
  const id = Number(todoItem.dataset.id);

  toggleTodo(id);
});

And now the function itself:

function toggleTodo(id) {
  todos = todos.map((todo) =>
    todo.id === id ? { ...todo, completed: !todo.completed } : todo
  );

  saveTodos();
  renderTodos();
}

This works by creating a new array where the matching task gets its completed value flipped.

Using map() like this is a clean and readable way to update state.


Step 10: Delete tasks

Deleting a task should be simple.

function deleteTodo(id) {
  todos = todos.filter((todo) => todo.id !== id);
  saveTodos();
  renderTodos();
}

We keep every task except the one with the matching id.

This is the kind of code that feels almost too simple, but that is actually a good sign. Good code often looks simple once the structure is clear.


Step 11: Edit tasks

Editing makes the app feel much more complete.

We will use prompt() for a straightforward version first.

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;
  }

  todos = todos.map((item) =>
    item.id === id ? { ...item, text: trimmedText } : item
  );

  saveTodos();
  renderTodos();
}

This is not the fanciest editing system, but it works well for a simple project.

Later, you could improve it by turning the task into an inline input field instead of using a prompt. But prompt-based editing is a great first step because it is easy to understand.


Step 12: Add filtering

Filtering makes the app useful when the task list grows.

We already created filter buttons in HTML. Now let’s 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();
  });
});

This does three things:

  • Removes the active class from all buttons

  • Adds active styling to the selected button

  • Updates the current filter

  • Re-renders the list

Because renderTodos() reads from currentFilter, the displayed tasks change automatically.

That is a good example of why separating data from display logic helps. The filter does not need to rebuild everything manually. It just changes the state and lets the render function do the work.


Step 13: Clear completed tasks

Sometimes users want a clean slate without deleting everything.

That is where the “Clear Completed” button helps.

clearCompletedBtn.addEventListener("click", function () {
  todos = todos.filter((todo) => !todo.completed);
  saveTodos();
  renderTodos();
});

This removes every completed task from the array and refreshes the list.

It is a small feature, but it makes the app feel much more practical.


Step 14: Load the initial view

Now we need to show the tasks when the page loads.

renderTodos();

That is it.

Since todos is already populated from localStorage earlier, rendering once at the beginning is enough to display saved tasks immediately.


Step 15: Put everything together

Here is the full app.js file all together.

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 = JSON.parse(localStorage.getItem("todos")) || [];
let currentFilter = "all";

function saveTodos() {
  localStorage.setItem("todos", JSON.stringify(todos));
}

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;
  });

  if (filteredTodos.length === 0) {
    todoList.innerHTML = `<li class="empty-state">No tasks found.</li>`;
    updateTaskCount();
    return;
  }

  filteredTodos.forEach((todo) => {
    const li = document.createElement("li");
    li.className = "todo-item";
    li.dataset.id = todo.id;

    li.innerHTML = `
      <div class="todo-content">
        <input type="checkbox" class="toggle-todo" ${todo.completed ? "checked" : ""} />
        <span class="todo-text ${todo.completed ? "completed" : ""}">${todo.text}</span>
      </div>
      <div class="todo-actions">
        <button class="edit-btn">Edit</button>
        <button class="delete-btn">Delete</button>
      </div>
    `;

    todoList.appendChild(li);
  });

  updateTaskCount();
}

todoForm.addEventListener("submit", function (e) {
  e.preventDefault();

  const text = todoInput.value.trim();

  if (text === "") {
    alert("Please enter a task.");
    return;
  }

  const newTodo = {
    id: Date.now(),
    text: text,
    completed: false
  };

  todos.push(newTodo);
  saveTodos();
  renderTodos();

  todoInput.value = "";
  todoInput.focus();
});

todoList.addEventListener("click", function (e) {
  const todoItem = e.target.closest(".todo-item");

  if (!todoItem) return;

  const id = Number(todoItem.dataset.id);

  if (e.target.classList.contains("delete-btn")) {
    deleteTodo(id);
  }

  if (e.target.classList.contains("edit-btn")) {
    editTodo(id);
  }
});

todoList.addEventListener("change", function (e) {
  if (!e.target.classList.contains("toggle-todo")) return;

  const todoItem = e.target.closest(".todo-item");
  const id = Number(todoItem.dataset.id);

  toggleTodo(id);
});

function toggleTodo(id) {
  todos = todos.map((todo) =>
    todo.id === id ? { ...todo, completed: !todo.completed } : 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;
  }

  todos = todos.map((item) =>
    item.id === id ? { ...item, text: trimmedText } : item
  );

  saveTodos();
  renderTodos();
}

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();
});

renderTodos();

At this stage, the app is fully functional.


Step 16: Make the app feel better

A working app is good. A pleasant app is better.

Here are a few small enhancements you can add.

1. Enter-to-add already works because we used a form

That is a nice little UX improvement already handled for us.

2. Focus the input after adding a task

We already included that too.

3. Show an empty state

That helps users understand what is happening.

4. Improve button hover states

The CSS already adds that.

5. Make the layout responsive

Our CSS uses flexible widths and wrapping, so it already behaves well on smaller screens.

These touches might seem minor, but they shape the emotional experience of using the app. People do not only notice whether something works. They also notice how it feels.


Step 17: Add a nicer empty state message

Instead of just saying “No tasks found,” you could make the empty state more human.

For example:

<li class="empty-state">No tasks here yet. Add one above and get started.</li>

That sounds warmer.

Tiny wording changes matter. A to-do app is a simple thing, but it still speaks to the user. Friendly writing gives it personality.


Step 18: Improve the input experience

You can also make the input slightly better by adding a maximum length or helpful aria labels.

<input 
  type="text" 
  id="todo-input" 
  placeholder="Add a new task..." 
  autocomplete="off"
  maxlength="100"
  aria-label="Task name"
/>

A maximum length can prevent overly long tasks from breaking your layout. The aria-label helps accessibility.

Accessibility is worth caring about even in small projects. A good app should not only look nice; it should also be easy to use for more people.


Step 19: Replace prompt editing with inline editing

If you want a more modern editing experience, you can make the task text editable directly in the list.

That version is more advanced, but it is worth exploring.

Instead of using a prompt, you might render an input field when the user clicks Edit.

A very simple version could look like this idea:

function editTodo(id) {
  const todoItem = document.querySelector(`.todo-item[data-id="${id}"]`);
  const todo = todos.find((todo) => todo.id === id);

  if (!todoItem || !todo) return;

  const textElement = todoItem.querySelector(".todo-text");

  const input = document.createElement("input");
  input.type = "text";
  input.value = todo.text;
  input.className = "edit-input";

  textElement.replaceWith(input);
  input.focus();

  input.addEventListener("blur", function () {
    const newText = input.value.trim();

    if (newText === "") {
      renderTodos();
      return;
    }

    todos = todos.map((item) =>
      item.id === id ? { ...item, text: newText } : item
    );

    saveTodos();
    renderTodos();
  });

  input.addEventListener("keydown", function (e) {
    if (e.key === "Enter") {
      input.blur();
    }

    if (e.key === "Escape") {
      renderTodos();
    }
  });
}

This is more involved, but it creates a smoother user experience.

A project like this is perfect for experimenting once the basic version is done.


Step 20: Add a dark mode

A dark mode toggle can make the app feel more complete.

You would add a button such as:

<button id="theme-toggle">Toggle Theme</button>

Then use a class on the body to switch styles.

Example JavaScript:

const themeToggle = document.getElementById("theme-toggle");

themeToggle.addEventListener("click", function () {
  document.body.classList.toggle("dark-mode");
});

Example CSS:

body.dark-mode {
  background: linear-gradient(135deg, #111827, #1f2937);
  color: #f9fafb;
}

body.dark-mode .todo-card {
  background: #1f2937;
  color: #f9fafb;
}

body.dark-mode .todo-item {
  background: #111827;
  border-color: #374151;
}

This is not required, but it is a great follow-up feature if you want to continue improving the app.


Step 21: Add task statistics

You can make the app more informative by showing:

  • total tasks

  • active tasks

  • completed tasks

Example:

function updateStats() {
  const total = todos.length;
  const active = todos.filter((todo) => !todo.completed).length;
  const completed = todos.filter((todo) => todo.completed).length;

  document.getElementById("stats").textContent =
    `Total: ${total} | Active: ${active} | Completed: ${completed}`;
}

Then call updateStats() whenever tasks change.

This is useful because it gives the user a quick overview of progress. In a simple productivity app, seeing progress is often half the motivation.


Step 22: Understand the logic behind state updates

One of the most valuable lessons in this project is how the app treats state.

The tasks live in the todos array. Every user action changes that array. After each change, the app:

  1. saves the new data

  2. re-renders the list

That is the cycle.

This pattern is powerful because it keeps the code organized. Instead of manually updating different parts of the DOM in different places, you update the state first and then redraw the UI from that state.

That is a concept that scales far beyond to-do apps. Once you understand it, you are already thinking like someone who can build more advanced applications.


Step 23: Common mistakes to avoid

When building a JavaScript to-do app, beginners often run into a few common problems.

Forgetting to call preventDefault()

If you forget this on the form submit event, the page reloads and your task disappears immediately.

Not trimming the input

Without .trim(), users can add blank tasks that only contain spaces.

Updating the DOM in too many places

This can make the app harder to debug.

Not saving after changes

If you forget to call saveTodos(), refreshes will erase data.

Using non-unique IDs

If tasks share the same ID, delete and edit actions may behave unpredictably.

Overcomplicating the first version

Start simple. Build the core features first, then improve them.

These are normal mistakes. Everyone makes them at some point. The key is learning how to spot them quickly.


Step 24: Optional upgrades

Once the basic app works, you can add more features.

Here are some ideas:

  • Drag and drop reordering

  • Due dates

  • Priority levels

  • Categories or tags

  • Search bar

  • Task counters by category

  • Import and export JSON

  • Keyboard shortcuts

  • Undo delete

  • Subtasks

  • Progress bar

You do not need all of these at once. In fact, the smartest approach is usually to add one feature at a time. Every small upgrade teaches you something new.

A to-do app is one of the best playgrounds for that kind of learning.


Step 25: Full HTML, CSS, and JavaScript recap

Here is a compact recap of the project structure.

HTML

  • Form for adding tasks

  • List for tasks

  • Filter buttons

  • Footer for task count and clear completed button

CSS

  • Centered card layout

  • Clean spacing

  • Rounded buttons

  • Completed task styling

  • Responsive behavior

JavaScript

  • Store tasks in an array

  • Render tasks from state

  • Add, edit, delete, and toggle tasks

  • Filter by status

  • Save to localStorage

  • Update the task counter

That is the full system in one view.


Step 26: Why this project matters

A to-do app may seem simple, but it teaches a lot.

It teaches you how to:

  • Work with the DOM

  • Handle events

  • Store data

  • Use arrays and objects

  • Write reusable functions

  • Keep code organized

  • Build something users can actually use

That is real value.

And honestly, there is something satisfying about building a tiny app that helps organize life, even in a small way. It makes the code feel less abstract. You are not just writing JavaScript for practice. You are creating a tool.

That human feeling matters.

When you build something that solves a small real-world problem, even in a simple way, the lesson stays with you longer.


Final thoughts

You now have a complete step-by-step to-do app built with JavaScript.

We started with a basic HTML layout, added styling, then built the functionality piece by piece:

  • Add tasks

  • Toggle completion

  • Edit tasks

  • Delete tasks

  • Filter tasks

  • Save everything in local storage

  • Clear completed tasks

  • Count remaining work

This is a strong beginner project, but it is also a flexible foundation. You can keep improving it as your JavaScript skills grow.

That is the best part of projects like this: they are small enough to finish, but rich enough to keep teaching you.

Once you finish this version, try making your own version with a different design or extra features. That is where the real learning begins.