How to Implement a Todo List with Add, Edit, Delete, and Filter Functionality in Vanilla JavaScript

You can build a fully functional todo application using only native DOM APIs and localStorage by cloning the template-based architecture from the jisan-mia/dom-projects repository, which implements add, edit, delete, toggle, and filter features without any external dependencies.

The Dom-Projects repository provides a production-ready reference implementation located in projects/advanced-todo/. This version demonstrates how to manage state persistence, manipulate the DOM using HTML templates, and handle user interactions through event delegation. Below is a complete technical breakdown of the implementation, including file paths, function signatures, and runnable code examples.

Project Structure and File Overview

The application is contained within three files in the projects/advanced-todo/ directory:

  • index.html – Defines the layout, filter controls, input form, and an HTML <template> element for list items.
  • script.js – Contains all business logic: state management, rendering, and event handling (lines 1–125).
  • style.css – Provides minimal styling for layout, checkboxes, and button states.

Setting Up the HTML Template and Layout

The markup uses a <template> element to define the structure of a single todo item. This avoids manual string concatenation and ensures consistent DOM nodes.

Key elements in index.html:

  • List container: <ul class="list"></ul> receives cloned template instances.
  • Filter bar: Buttons with data-role attributes (all, active, completed) control visibility.
  • Input form: A .todo-form with a text input and submit button handles new items and edits.
  • Template: The #list-item-template contains a checkbox (data-list-item-checkbox), text span (data-list-item-text), and action buttons.

Implementing Data Persistence with localStorage

State is synchronized to localStorage under the key ADVANCE_TODO_LIST-todos (lines 63–71 of script.js).

const LOCAL_STORAGE_KEY = "ADVANCE_TODO_LIST-todos";
let todoList = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY)) || [];

function saveTodo() {
  localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(todoList));
}
  • getTodo() (implicitly handled by the initial parse) retrieves the JSON array or returns an empty array.
  • saveTodo() serializes the current todoList array back to storage after every mutation.

Rendering Todos from the DOM Template

The rendering engine clones the HTML template for each todo object.

function renderTodo(todo) {
  const tmpl = document.querySelector('#list-item-template').content.cloneNode(true);
  const li = tmpl.querySelector('.list-item');
  li.dataset.todoId = todo.id;
  tmpl.querySelector('[data-list-item-text]').textContent = todo.name;
  tmpl.querySelector('[data-list-item-checkbox]').checked = todo.isCompleted;
  document.querySelector('.list').appendChild(tmpl);
}

function renderAllTodo() {
  todoList.forEach(renderTodo);
}
  • renderTodo(todo) (lines 76–81) populates the template and appends it to the .list container.
  • renderAllTodo() (lines 12–14) iterates over the loaded array on initial page load.

Adding New Todos

The form submit handler creates a new todo object, pushes it to the state, and renders it immediately (lines 27–48).

document.querySelector('.todo-form').addEventListener('submit', e => {
  e.preventDefault();
  const input = document.getElementById('todo-input');
  if (!input.value) return;
  
  const newTodo = { 
    id: Date.now(), 
    name: input.value, 
    isCompleted: false 
  };
  
  todoList.push(newTodo);
  renderTodo(newTodo);
  saveTodo();
  input.value = '';
});

The id is generated using Date.now() to ensure uniqueness.

Editing Existing Todos

Editing uses a two-phase flow: selecting the item and updating the state.

let selectedEditTodo = null;

// Phase 1: Select for editing
document.querySelector('.list').addEventListener('click', e => {
  if (!e.target.matches('.edit-todo')) return;
  const li = e.target.closest('.list-item');
  selectedEditTodo = todoList.find(t => t.id == li.dataset.todoId);
  document.getElementById('todo-input').value = selectedEditTodo.name;
});

// Phase 2: Submit edit
function editTodo(newName) {
  todoList = todoList.map(t => 
    t.id === selectedEditTodo.id ? { ...t, name: newName } : t
  );
  const li = document.querySelector(`[data-todo-id="${selectedEditTodo.id}"]`);
  li.querySelector('[data-list-item-text]').textContent = newName;
  saveTodo();
  selectedEditTodo = null;
  document.getElementById('todo-input').value = '';
}

The editTodo function (referenced in lines 55–74 of script.js) updates the array immutably using map, mutates the specific DOM node to avoid a full re-render, and clears the edit state.

Deleting and Toggling Completion

Event delegation handles both deletion and completion toggling without binding listeners to dynamic elements.

Toggle Completion (lines 14–27):

document.querySelector('.list').addEventListener('change', e => {
  if (!e.target.matches('[data-list-item-checkbox]')) return;
  const li = e.target.closest('.list-item');
  const todo = todoList.find(t => t.id == li.dataset.todoId);
  todo.isCompleted = e.target.checked;
  saveTodo();
});

Delete (lines 29–39):

document.querySelector('.list').addEventListener('click', e => {
  if (!e.target.matches('.delete-todo')) return;
  const li = e.target.closest('.list-item');
  todoList = todoList.filter(t => t.id != li.dataset.todoId);
  li.remove();
  saveTodo();
});

Both handlers use closest('.list-item') to traverse from the event target to the parent node containing the data-todo-id attribute.

Filtering Todos by Status

The filter implementation re-renders the list based on the activeFilter state (lines 85–125).

let activeFilter = 'all';

document.querySelector('.filter-section').addEventListener('click', e => {
  if (!e.target.matches('button')) return;
  activeFilter = e.target.dataset.role; // 'all', 'active', or 'completed'
  
  const list = document.querySelector('.list');
  list.innerHTML = '';
  
  const filtered = activeFilter === 'all' 
    ? todoList 
    : activeFilter === 'active' 
      ? todoList.filter(t => !t.isCompleted) 
      : todoList.filter(t => t.isCompleted);
      
  filtered.forEach(renderTodo);
});

This approach clears the DOM and re-clones the template for the filtered subset, ensuring the UI stays synchronized with the activeFilter variable.

Summary

  • Architecture: The jisan-mia/dom-projects repository uses a template-based rendering system with localStorage persistence, eliminating external dependencies.
  • State Management: The todoList array serves as the single source of truth, synchronized to ADVANCE_TODO_LIST-todos in localStorage via saveTodo().
  • CRUD Operations:
    • Create: Form submission generates a new object with Date.now() ID.
    • Read: renderTodo() clones the HTML template and populates data attributes.
    • Update: editTodo() uses immutable array mapping and targeted DOM updates.
    • Delete: Event delegation removes items from both the array and DOM.
  • Filtering: The activeFilter state drives conditional array filtering before re-rendering the list container.

Frequently Asked Questions

How does the todo list persist data between page refreshes?

The application stores the todoList array in the browser's localStorage under the key ADVANCE_TODO_LIST-todos. The saveTodo() function (lines 63–71 of script.js) serializes the array to JSON on every mutation, while the initial load uses JSON.parse() to hydrate the state or defaults to an empty array.

What is the purpose of the HTML template element in this implementation?

The <template id="list-item-template"> in index.html defines the structure of a single todo item (checkbox, text span, and action buttons) without rendering it immediately. The renderTodo() function clones this template's content using document.querySelector('#list-item-template').content.cloneNode(true), then populates the data-todo-id, text content, and checkbox state before appending it to the live DOM. This approach keeps markup DRY and avoids string concatenation.

How does the filtering mechanism work without reloading the page?

Filtering is handled entirely client-side by the showFilteredTodos() logic (lines 85–125 of script.js). When a filter button is clicked, the activeFilter variable updates to all, active, or completed. The handler clears the .list container's innerHTML, filters the todoList array based on the isCompleted property, and re-invokes renderTodo() for each matching item. This re-renders only the filtered subset instantly without a server request.

Can multiple todo items be edited simultaneously in this implementation?

No, the implementation restricts editing to one item at a time using the selectedEditTodo variable. When the Edit button is clicked, the corresponding todo object is stored in selectedEditTodo and its text populates the input field. The form submit handler checks if selectedEditTodo exists; if so, it calls editTodo() and clears the variable. This prevents race conditions and ensures the UI only shows one item in edit mode at a time.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →