CRUD Operations End to End
Objectives
By the end of this chapter, you should be able to:
- Build a page that creates, reads, updates, and deletes the same kind of resource
- Wire each operation to its correct HTTP method
- Explain why a practice API’s create/update/delete calls don’t actually persist
💡 Why this matters: Real pages rarely use just one HTTP method in isolation. A task list loads tasks (
GET), adds one (POST), checks one off (PATCH), and removes one (DELETE), all in the same page. This lesson builds exactly that.
⚠️ A note on verification: the
GETrequest below was run against the real, live JSONPlaceholder API. ThePOST,PATCH, andDELETEcalls can’t run from this sandbox (the same limitation as this module’s earlier lessons), the DOM logic, event wiring, and request shapes were verified by simulating the API’s well-documented responses directly.
What You’re Building
A task manager: load a real user’s tasks from a server, add a new one, check tasks complete, and delete them, all four CRUD operations against the same resource.
task-manager/
├── index.html
└── script.js
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Task Manager</title>
</head>
<body>
<h1>Task Manager</h1>
<input id="taskInput" type="text" placeholder="New task">
<button id="addBtn">Add Task</button>
<ul id="taskList"></ul>
<p id="status"></p>
<script src="script.js"></script>
</body>
</html>
Everything from here on goes in script.js.
Step 1: Read, Rendering Each Task
const taskInput = document.getElementById("taskInput");
const addBtn = document.getElementById("addBtn");
const taskList = document.getElementById("taskList");
const status = document.getElementById("status");
function renderTask(todo) {
const li = document.createElement("li");
li.dataset.id = todo.id;
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "toggle";
checkbox.checked = todo.completed;
const span = document.createElement("span");
span.className = "title";
span.textContent = todo.title;
const deleteBtn = document.createElement("button");
deleteBtn.className = "delete";
deleteBtn.textContent = "Delete";
li.append(checkbox, span, deleteBtn);
taskList.appendChild(li);
}
li.dataset.id = todo.id (Module 1) stores each task’s real id directly on its element, in a data-id attribute, so later steps can read it back to know which task an action applies to, without needing a separate lookup.
Step 2: Read, Loading Real Tasks
async function loadTasks() {
status.textContent = "Loading...";
const response = await fetch("https://jsonplaceholder.typicode.com/users/1/todos");
const todos = await response.json();
todos.slice(0, 5).forEach(renderTask);
status.textContent = `Loaded ${Math.min(todos.length, 5)} tasks`;
}
loadTasks();
/users/1/todos (Module 6, lesson 1’s nesting pattern) returns every todo belonging to user 1, JSONPlaceholder gives that user 20. .slice(0, 5) keeps this demo to the first five, so the page doesn’t end up with an unwieldy list. This is a plain GET, no method needed, fetch(url) defaults to it.
Step 3: Create
addBtn.addEventListener("click", async () => {
const title = taskInput.value.trim();
if (!title) return;
const response = await fetch("https://jsonplaceholder.typicode.com/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, completed: false, userId: 1 }),
});
const created = await response.json();
renderTask(created);
taskInput.value = "";
status.textContent = "Task added";
});
The server responds with the created object, including a new id it assigned, renderTask(created) uses that real response rather than guessing what the id would be. Sending completed: false explicitly means every new task starts unchecked, matching what the checkbox in renderTask() expects.
Step 4: Update, Toggling Complete
Since every task’s checkbox is created after the page loads (Step 1 and Step 3 both call renderTask()), a listener attached individually to each one would miss tasks added later. This is delegation (Module 2, lesson 4) again: one listener on #taskList handles every task’s checkbox and delete button, present and future.
taskList.addEventListener("click", async (event) => {
const li = event.target.closest("li");
if (!li) return;
const id = li.dataset.id;
if (event.target.classList.contains("toggle")) {
const completed = event.target.checked;
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed }),
});
status.textContent = `Task ${id} updated`;
}
});
event.target.checked reads the checkbox’s state at the moment it was clicked, the browser toggles a checkbox’s .checked before the click event fires, so this always reflects the new state, not the old one. PATCH (Module 6, lesson 2) sends only { completed }, not the whole task, exactly the “partial update” distinction from that lesson.
Step 5: Delete
taskList.addEventListener("click", async (event) => {
const li = event.target.closest("li");
if (!li) return;
const id = li.dataset.id;
if (event.target.classList.contains("toggle")) {
const completed = event.target.checked;
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed }),
});
status.textContent = `Task ${id} updated`;
}
if (event.target.classList.contains("delete")) {
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: "DELETE",
});
li.remove();
status.textContent = `Task ${id} deleted`;
}
});
Both branches live in the same delegated listener, if (!li) return; guards against a click landing outside any task row, then each classList.contains() check decides which action actually happened. DELETE needs no body, the URL, /todos/${id}, already identifies exactly what to remove.
A Note on This Practice API
JSONPlaceholder’s POST, PATCH, and DELETE all respond as if they worked, correct status codes, a sensible-looking response body, but nothing is actually saved. Reloading this page would show the original 20 tasks again, any task you added, checked off, or deleted during this session would be back to its starting state. That’s by design (Module 3 covered this same behavior), and it’s exactly why this is a practice API rather than a real one: full CRUD behavior to practice against, zero risk of actually breaking anything.
Try It
Build the full task manager from this lesson:
- Wire up Steps 1 and 2, confirming the first five of user 1’s tasks render on load.
- Add Step 3, and confirm a newly added task appears immediately, using the real
idthe server returned. - Add Steps 4 and 5 together as one delegated listener, and confirm both checking a task and deleting one work correctly, on tasks loaded initially and on the one you just added.
- Change
loadTasks()to load user2’s tasks instead of user1’s, and confirm the page still works exactly the same way, nothing about Steps 3-5 needed to change for that.
Recap
- One page, four operations:
GETto load,POSTto create,PATCHto update,DELETEto remove, each wired to the HTTP method that matches its intent (Module 6, lesson 2). - Delegation (Module 2) handled both the toggle and delete actions with a single listener on
#taskList, since tasks (and their checkboxes and delete buttons) are created after the page loads. event.target.checkedinside a delegatedclicklistener reliably reflects a checkbox’s new state, the browser toggles it before the event fires.- A practice API simulating writes without persisting them is normal and useful, real CRUD behavior to build against, without a real database to accidentally break.
Next lesson: a first look at WebSockets, for when polling with fetch() repeatedly isn’t enough.