LEARN TO CODE | JAVASCRIPT | 04 APIS AND MINI PROJECTS
04.4 Project: a to-do list
Create a small persistent browser app while keeping its task data, rendered list and save status consistent.
01 | BUILD A USEFUL SMALL APP
A to-do list brings together most of this course. The visitor types a task, adds it, marks it complete and deletes it when no longer needed. Reloading should restore successfully saved tasks.
Keep the first version focused: short non-sensitive tasks, at most 50 records, one browser profile and no account. This is not a shared team task service or a cloud backup.
The program must also explain a failed save. A task visible on the page is not necessarily safely stored for the next visit.
Use VS Code to edit index.html, script.js and style.css in one project folder. Link the external script with defer. Save changes and refresh the browser; inspect both the visible page and the Console.
The complete download has a separate folder for each project. Keep the examples independent to avoid colliding top-level variable names.
Serve the project over local HTTP for fetch and reliable storage testing. If your VS Code setup has a teacher-approved preview extension, use its preview command. With Live Server installed, right-click index.html and choose Open with Live Server. Keep the same hostname and port when testing saved data.
The example code runs in the browser and does not require Node.js. Opening a file URL is not the right workflow for the JSON fetch project.
02 | MODEL EACH TASK
const task = { id: 1, text: "Review array methods", done: false };
id identifies the record; text is its description; done is a boolean. The tasks array is the application’s source of truth. The list in the DOM is a rendering of those records.
Do not use the task’s array index as its permanent identity. Deleting an earlier task shifts later indexes, but the remaining records should keep their own IDs.
This local demo uses increasing integer IDs with a deliberate upper limit. It does not solve globally unique IDs for multi-user systems.
03 | DEFINE THE INPUT AND STORAGE RULES
| Rule | Reason |
|---|---|
| Trimmed text, length 1–80 | Reject blank and oversized tasks |
| At most 50 records | Keep this synchronous storage example small |
| Positive unique safe integer IDs | Target the intended record reliably |
| Boolean done values | Avoid treating text "false" as false |
| Versioned JSON wrapper | Recognise the expected saved format |
Length uses JavaScript string.length, so the limit counts UTF-16 code units. Duplicated task text is allowed; duplicated IDs are not. The Set in the loader tracks IDs already seen.
04 | ADD DATA, THEN RENDER IT
The submit listener prevents navigation, trims the text, checks the limits and pushes a new record into tasks. It then rebuilds the list, attempts to save and returns focus to the cleared input.
The nextId counter increases after each addition. On loading, it is derived from the largest existing ID rather than simply tasks.length + 1, because previously deleted IDs may leave gaps.
When input is invalid, the program shows a message without adding a record. The typed text remains available for correction.
05 | BUILD TASK CONTROLS FROM RECORDS
renderTasks creates a checkbox, a text span and a Delete button for every record. The checkbox’s checked property reflects task.done.
Its listener closes over that task reference. On change, it updates the record, toggles its text’s completed class, updates the count and attempts a save. It does not need to replace the focused checkbox just to show a strike-through.
The Delete listener filters by the stable ID, rebuilds the remaining list and moves focus to a nearby Delete button or the input. A descriptive aria-label includes the task text.
06 | COMPLETE TO-DO PROGRAM
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>A saved to-do list</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<main>
<h1>A saved to-do list</h1>
<p>Save short, non-sensitive practice tasks in this browser. There is no account or cloud sync.</p>
<form id="todo-form" novalidate>
<p><label for="todo-text">New task, 1 to 80 characters : </label> <input id="todo-text" name="task" type="text" maxlength="80" required autocomplete="off"></p>
<button type="submit">Add task</button>
</form>
<p id="todo-count" role="status" aria-live="polite"></p>
<ul id="todo-list"></ul>
<button id="todo-forget" type="button">Forget the saved copy</button>
<p id="todo-status" role="status" aria-live="polite"></p>
</main>
</body>
</html>
script.js
const taskKey = "csc-js04-tasks-v1";
const form = document.querySelector("#todo-form");
const input = document.querySelector("#todo-text");
const list = document.querySelector("#todo-list");
const count = document.querySelector("#todo-count");
const status = document.querySelector("#todo-status");
let tasks = [];
let nextId = 1;
let persistenceEnabled = true;
function validTaskRecord(value) {
if (value === null || typeof value !== "object" || Array.isArray(value)
|| value.version !== 1 || !Array.isArray(value.tasks) || value.tasks.length > 50) return false;
const ids = new Set();
for (const task of value.tasks) {
if (task === null || typeof task !== "object" || Array.isArray(task)
|| !Number.isSafeInteger(task.id) || task.id < 1 || task.id > 1000000
|| ids.has(task.id) || typeof task.text !== "string" || task.text.trim() === "" || task.text.length > 80
|| typeof task.done !== "boolean") return false;
ids.add(task.id);
}
return true;
}
function loadTasks() {
try {
const text = localStorage.getItem(taskKey);
if (text === null) return;
const value = JSON.parse(text);
if (!validTaskRecord(value)) throw new Error("Invalid task record");
tasks = value.tasks;
nextId = tasks.reduce((largest, task) => Math.max(largest, task.id), 0) + 1;
status.textContent = "Saved tasks restored.";
} catch (error) {
persistenceEnabled = false;
status.textContent = "Saved tasks could not be loaded. Use this session only, or forget the saved copy before enabling saves again.";
}
}
function saveTasks() {
if (!persistenceEnabled) {
status.textContent = "This change is in memory only. Forget the saved copy to enable saves again.";
return;
}
try {
localStorage.setItem(taskKey, JSON.stringify({ version: 1, tasks }));
status.textContent = "Saved in this browser.";
} catch (error) {
status.textContent = "Changed on this page, but not saved. Storage may be blocked or full.";
}
}
function renderTasks() {
list.replaceChildren();
for (const task of tasks) {
const item = document.createElement("li");
const label = document.createElement("label");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = task.done;
const text = document.createElement("span");
text.textContent = ` ${task.text} `;
text.classList.toggle("done", task.done);
label.append(checkbox, text);
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "Delete";
remove.setAttribute("aria-label", `Delete task: ${task.text}`);
checkbox.addEventListener("change", () => {
task.done = checkbox.checked;
text.classList.toggle("done", task.done);
updateCount();
saveTasks();
});
remove.addEventListener("click", () => {
const oldPosition = tasks.findIndex((record) => record.id === task.id);
tasks = tasks.filter((record) => record.id !== task.id);
renderTasks();
saveTasks();
const buttons = list.querySelectorAll("button");
if (buttons.length === 0) input.focus();
else buttons[Math.min(oldPosition, buttons.length - 1)].focus();
});
item.append(label, remove);
list.append(item);
}
updateCount();
}
function updateCount() {
const completed = tasks.filter((task) => task.done).length;
count.textContent = `${tasks.length} task(s), ${completed} completed, ${tasks.length - completed} remaining.`;
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const text = input.value.trim();
if (text === "" || text.length > 80) {
status.textContent = "Enter a task of 1 to 80 characters.";
input.focus();
return;
}
if (tasks.length >= 50 || nextId > 1000000) {
status.textContent = "This small demo has reached its task or ID limit.";
return;
}
tasks.push({ id: nextId, text, done: false });
nextId += 1;
input.value = "";
renderTasks();
saveTasks();
input.focus();
});
document.querySelector("#todo-forget").addEventListener("click", () => {
try {
localStorage.removeItem(taskKey);
persistenceEnabled = true;
status.textContent = "Saved copy removed; tasks remain on this page. The next task change will save the current list again.";
} catch (error) {
status.textContent = "Saved copy could not be removed. Page tasks are unchanged.";
}
});
loadTasks();
renderTasks();
The download’s stylesheet includes .done { text-decoration: line-through; }. Styling does not determine the stored completion value; the boolean record does.
07 | TRY ADDING, COMPLETING AND DELETING
Save short, non-sensitive practice tasks in this browser. There is no account or cloud sync.
Use fictional practice tasks. Add two, complete one and delete the other. Check that the count agrees with the displayed controls, then reload after a successful save.
TRY IT YOURSELF
Enter the literal text <b>Practise loops</b>. Should it become bold markup?
Reveal guidance
No. The task is displayed through textContent and stays literal text. The checkbox state and record ID are separate from the text.
08 | VALIDATE EVERY RESTORED RECORD
loadTasks reads one namespaced key, parses JSON and validates the version, array length and every task. It rejects duplicated IDs, invalid booleans and unsuitable text.
If loading fails or the saved format is invalid, the page starts with an empty in-memory list and disables automatic persistence. It does not silently overwrite an unreadable saved copy when the visitor adds a temporary task.
The status explains that the current list is session-only until the visitor deliberately forgets the saved copy. This makes recovery a visible decision instead of an accidental data replacement.
09 | HANDLE SAVE FAILURE HONESTLY
A write can fail even when loading worked, for example if storage later becomes full. The page keeps the current in-memory tasks but says the change was not saved.
Reloading after such a failure can restore an older successfully saved list. That is why the warning matters: visible state and persistent state have diverged.
There is no cloud sync, automatic backup or cross-tab conflict handling. Two tabs editing the same key can overwrite each other. This app is deliberately a single-active-tab practice tool.
10 | FORGET THE SAVED COPY PRECISELY
The Forget button removes this application’s storage key only. It deliberately leaves the current page’s tasks visible and says that the next task change will save the current list again.
It is not a Delete all tasks button. The wording distinguishes the saved copy from the in-memory list. If you want an empty saved list, delete the tasks normally; a successful save writes an empty tasks array.
If key removal fails, the program leaves state alone and reports failure. It never clears unrelated keys from the origin.
11 | TEST NORMAL USE AND RECOVERY
| Test | Expected |
|---|---|
| Blank/spaces-only task | Reject; no new record |
| Add two, complete one | 2 tasks, 1 completed, 1 remaining |
| Delete earlier task, then add another | No duplicate IDs; intended records remain |
| Reload after successful changes | Text and done values restored |
| Invalid JSON or duplicate saved IDs | Controlled warning; session-only mode |
| Blocked/full storage | Useful warning without losing current page state |
| Forget with unrelated key present | Only this app’s saved copy is removed |
TRY IT YOURSELF
Add a Remaining-only filter. Should filtering delete completed records from tasks?
Reveal guidance
No. Keep the full data array and choose a filtered view during rendering. Deleting records would change data, not merely change what is currently shown.
KEEP LEARNING
Previous page 04 APIs and mini projects: challenges
Download Section 04 example programs
Further reading: Promises · Fetch · JSON · Browser storage