Title: I built a task manager with zero frameworks, and the "delete confirmation" part took longer than everything else combined
I've been doing a lot of React and Next.js lately, so I gave myself a small constraint for this one: no framework, no build step, just the DOM API and localStorage. A task manager. Add, edit, delete, persist. Sounds like an afternoon project.
It mostly was. Except for one part I didn't expect to be annoying.
The setup
Nothing fancy. One form, toggled open and closed with a hidden class, one container that gets re-rendered every time the task list changes:
const taskForm = document.getElementById("task-form");
const tasksContainer = document.getElementById("tasks-container");
const taskData = JSON.parse(localStorage.getItem("data")) || [];
let currentTask = {};
That currentTask variable is doing more work than it looks like. It's empty when you're adding a new task, and it holds a copy of the task you clicked "Edit" on when you're not. Everything else in the app branches off whether that object has an id or not.
Add and edit share one function, on purpose
I didn't want two nearly-identical functions for creating vs. updating a task, so addOrUpdateTask checks whether the current task already exists in the array:
const addOrUpdateTask = () => {
if (!titleInput.value.trim()) {
alert("Please provide a title");
return;
}
const dataArrIndex = taskData.findIndex((item) => item.id === currentTask.id);
const taskObj = {
id: `${removeSpecialChars(titleInput.value).toLowerCase().split(" ").join("-")}-${Date.now()}`,
title: removeSpecialChars(titleInput.value),
date: dateInput.value,
description: removeSpecialChars(descriptionInput.value),
};
if (dataArrIndex === -1) {
taskData.unshift(taskObj);
} else {
taskData[dataArrIndex] = taskObj;
}
localStorage.setItem("data", JSON.stringify(taskData));
updateTaskContainer();
reset();
};
If findIndex comes back -1, nothing matched, so it's a new task and gets pushed to the front. Otherwise it overwrites the existing entry in place. One function, one code path, no duplication.
The id itself is a slugified title plus a timestamp, so Fix the Bug!! becomes something like fix-the-bug-1719839213456. Good enough for a localStorage app where nobody's hitting a real database.
Where I actually spent my time
Rendering, storing, deleting, all straightforward. What I underestimated was: what happens when someone opens the form, types half a task, and then clicks the close button?
If I just closed the form, they'd lose their draft with zero warning. Annoying, but not exactly a rare thing to run into. So the close handler checks two things before doing anything: does the form actually have values in it, and are those values different from what's already saved for the current task?
closeTaskFormBtn.addEventListener("click", () => {
const formInputsContainValues = titleInput.value || dateInput.value || descriptionInput.value;
const formInputValuesUpdated =
titleInput.value !== currentTask.title ||
dateInput.value !== currentTask.date ||
descriptionInput.value !== currentTask.description;
if (formInputsContainValues && formInputValuesUpdated) {
confirmCloseDialog.showModal();
} else {
reset();
}
});
Both conditions matter. If you open the edit form and just close it without touching anything, formInputValuesUpdated is false, so it closes quietly, no dialog. But if you change even one field, both conditions flip true and you get a native <dialog> asking if you want to discard.
I went back and forth on whether this was overkill for a demo project. It's not. The first time I tested the app on my own to-do list and accidentally clicked outside the form, losing a real task I'd typed out, I stopped thinking of it as a nice-to-have.
The one-liner I keep reusing
const removeSpecialChars = (val) => {
return val.trim().replace(/[^A-Za-z0-9\-\s]/g, '');
};
Strips anything that isn't a letter, number, hyphen, or whitespace, and trims the ends. I run every text input through it before it touches the id or the stored task. It's a small thing, but it's saved me from a handful of "why does this id have three slashes in it" moments in other projects since.
What I'd change next time
updateTaskContainer rebuilds the entire task list's HTML from scratch on every change:
const updateTaskContainer = () => {
tasksContainer.innerHTML = "";
taskData.forEach(({ id, title, date, description }) => {
tasksContainer.innerHTML += `
<div class="task" id="${id}">
<p><strong>Title:</strong> ${title}</p>
<p><strong>Date:</strong> ${date}</p>
<p><strong>Description:</strong> ${description}</p>
<button onclick="editTask(this)" type="button" class="btn">Edit</button>
<button onclick="deleteTask(this)" type="button" class="btn">Delete</button>
</div>
`;
});
};
Fine for a handful of tasks. Once you're past a few hundred, wiping and rebuilding the whole DOM tree on every keystroke-adjacent action starts to show. I'd move to diffing individual task nodes, or honestly just reach for a tiny library at that point rather than hand-roll a virtual DOM for a to-do app.
The onclick attributes inline in the template string also bug me a little, mixing markup and behavior like that isn't something I'd do in anything bigger than this. But for a single-file project with no bundler, it's the path of least resistance, and I'm not going to pretend I have strong feelings about purity here.
Takeaway
Frameworks handle state synchronization for you. Building this by hand made me appreciate exactly how much "handle the close button" and "handle the delete button" and "keep localStorage in sync" actually costs when there's no framework doing it in the background. None of it is hard. There's just more of it than you'd think, and most of it lives in the edges, not the happy path.
Full code's on my GitHub if you want to poke at it. Happy to hear how you'd have handled the unsaved-changes check differently.
Top comments (0)