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-roleattributes (all,active,completed) control visibility. - Input form: A
.todo-formwith a text input and submit button handles new items and edits. - Template: The
#list-item-templatecontains 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 currenttodoListarray 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.listcontainer.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-projectsrepository uses a template-based rendering system withlocalStoragepersistence, eliminating external dependencies. - State Management: The
todoListarray serves as the single source of truth, synchronized toADVANCE_TODO_LIST-todosinlocalStorageviasaveTodo(). - 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.
- Create: Form submission generates a new object with
- Filtering: The
activeFilterstate 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →