CRUD — Create, Read, Update, Delete — is the core of almost every app: a to-do list, a contact book, an admin panel. In this guide you'll build a task manager in plain HTML, CSS and JavaScript that does all four, keeps tasks after a page reload, and is safe to use with any text a user types. At the end, you'll see the same four operations against a REST API with fetch.
What you'll build:
- Add a task by typing and pressing Enter.
- See all tasks, with a message when there are none.
- Edit a task in place (Enter saves, Escape cancels) and mark it done.
- Delete a task, with a confirmation.
- Tasks saved in localStorage, so they survive a reload.
Tested in: Chrome 152, October 2026.
What Is CRUD?
The same four operations show up whether your data lives in an array, a REST API or a database:
| Operation | In this app | JavaScript array | REST API (HTTP) | SQL |
|---|---|---|---|---|
| Create | Add a task | push() | POST | INSERT |
| Read | Show the list | map() to render | GET | SELECT |
| Update | Edit or mark done | map() with changes | PUT / PATCH | UPDATE |
| Delete | Remove a task | filter() | DELETE | DELETE |
Step 1: Set Up the Project
Create a folder with three files:
crud-app/
├── index.html
├── style.css
└── script.jsStep 2: The HTML
<!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>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="container">
<h1>Task Manager</h1>
<form id="task-form">
<label for="task-input">New task</label>
<div class="row">
<input id="task-input" type="text" maxlength="200" autocomplete="off" required>
<button type="submit">Add</button>
</div>
</form>
<p id="empty" class="empty">No tasks yet. Add your first one above.</p>
<ul id="task-list"></ul>
</main>
<script src="script.js"></script>
</body>
</html>- The input is inside a <form>, so pressing Enter submits it — no extra key handling needed.
- A visible <label> tells screen-reader users what the field is for. required and maxlength stop empty and overly long tasks before any JavaScript runs.
- The empty-state paragraph is hidden by the script once there are tasks.
Step 3: The CSS
body {
font-family: system-ui, sans-serif;
background: #f4f4f9;
margin: 0;
padding: 40px 16px;
}
.container {
max-width: 520px;
margin: 0 auto;
background: #fff;
padding: 24px;
border-radius: 10px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.08);
}
label {
display: block;
font-weight: 600;
margin-bottom: 6px;
}
.row {
display: flex;
gap: 8px;
}
input[type="text"] {
flex: 1;
padding: 10px;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 6px;
}
button {
padding: 8px 14px;
font-size: 14px;
border: 0;
border-radius: 6px;
background: #4f46e5;
color: #fff;
cursor: pointer;
}
button.secondary {
background: #e5e7eb;
color: #111;
}
button.danger {
background: #dc2626;
}
#task-list {
list-style: none;
padding: 0;
margin: 16px 0 0;
}
#task-list li {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 0;
border-bottom: 1px solid #eee;
}
#task-list .text {
flex: 1;
overflow-wrap: anywhere;
}
#task-list .done .text {
text-decoration: line-through;
color: #888;
}
.empty {
color: #666;
}Step 4: The JavaScript
The whole app is one array of task objects ({ id, text, done }), functions that change it, and a render() function that rebuilds the list from it:
const STORAGE_KEY = "tasks";
const form = document.getElementById("task-form");
const input = document.getElementById("task-input");
const list = document.getElementById("task-list");
const empty = document.getElementById("empty");
let tasks = loadTasks();
let editingId = null;
// --- Storage ---------------------------------------------------------------
function loadTasks() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
return Array.isArray(saved) ? saved : [];
} catch {
return []; // corrupted data, or storage is blocked
}
}
function saveTasks() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
} catch {
// storage full or blocked: keep working in memory
}
}
function newId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
// --- Create, Update, Delete ------------------------------------------------
function createTask(text) {
tasks.push({ id: newId(), text, done: false });
saveTasks();
render();
}
function updateTask(id, changes) {
tasks = tasks.map((task) => (task.id === id ? { ...task, ...changes } : task));
saveTasks();
render();
}
function deleteTask(id) {
tasks = tasks.filter((task) => task.id !== id);
saveTasks();
render();
}
// --- Read: render the list -------------------------------------------------
function makeButton(label, action, className = "") {
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = label;
btn.dataset.action = action;
if (className) btn.className = className;
return btn;
}
function render() {
list.replaceChildren(
...tasks.map((task) => {
const li = document.createElement("li");
li.dataset.id = task.id;
if (task.done) li.classList.add("done");
if (task.id === editingId) {
const edit = document.createElement("input");
edit.type = "text";
edit.value = task.text;
edit.maxLength = 200;
edit.className = "edit";
edit.setAttribute("aria-label", "Edit task");
li.append(edit, makeButton("Save", "save"), makeButton("Cancel", "cancel", "secondary"));
} else {
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = task.done;
checkbox.dataset.action = "toggle";
checkbox.setAttribute("aria-label", `Mark "${task.text}" as done`);
const text = document.createElement("span");
text.className = "text";
text.textContent = task.text; // never parsed as HTML
li.append(
checkbox,
text,
makeButton("Edit", "edit", "secondary"),
makeButton("Delete", "delete", "danger")
);
}
return li;
})
);
empty.hidden = tasks.length > 0;
list.querySelector(".edit")?.focus();
}
// --- Events ----------------------------------------------------------------
form.addEventListener("submit", (e) => {
e.preventDefault(); // stay on the page
const text = input.value.trim();
if (!text) return;
createTask(text);
form.reset();
input.focus();
});
function saveEdit(id) {
const text = list.querySelector(".edit").value.trim();
editingId = null;
if (text) updateTask(id, { text });
else render(); // an empty edit keeps the old text
}
// One listener handles the buttons of every task (event delegation)
list.addEventListener("click", (e) => {
const action = e.target.dataset.action;
const id = e.target.closest("li")?.dataset.id;
if (!action || !id) return;
if (action === "edit") {
editingId = id;
render();
} else if (action === "cancel") {
editingId = null;
render();
} else if (action === "save") {
saveEdit(id);
} else if (action === "delete" && confirm("Delete this task?")) {
deleteTask(id);
}
});
list.addEventListener("change", (e) => {
if (e.target.dataset.action === "toggle") {
updateTask(e.target.closest("li").dataset.id, { done: e.target.checked });
}
});
list.addEventListener("keydown", (e) => {
if (!e.target.classList.contains("edit")) return;
const id = e.target.closest("li").dataset.id;
if (e.key === "Enter") saveEdit(id);
if (e.key === "Escape") {
editingId = null;
render();
}
});
render();How each part works
- Storage: loadTasks() reads the saved array on startup and saveTasks() writes it after every change. Both are wrapped in try/catch: if the saved data is corrupted or storage is blocked, the app starts with an empty list instead of crashing.
- Create: the form's submit handler trims the text, ignores whitespace-only input, adds the task and clears the field. e.preventDefault() stops the browser from reloading the page.
- Read: render() builds each list item with createElement and textContent — never innerHTML (see the next section).
- Update: the checkbox toggles done. Edit swaps the text for an input; Enter or Save stores the new text, Escape or Cancel throws it away, and an empty edit keeps the old text.
- Delete: filter() removes the task after confirm().
- Event delegation: instead of a listener on every button, one listener on the list reads data-action and the task's data-id. It keeps working for tasks added later.
- IDs: Date.now() alone can repeat when two tasks are added in the same millisecond, so a random suffix is added.
Step 5: Test It
Open index.html in your browser and check that:
- Typing a task and pressing Enter adds it; a task of only spaces is ignored.
- Ticking the checkbox strikes the task through.
- Edit → change the text → Enter saves it; Escape cancels.
- Delete asks for confirmation and removes the task.
- After a reload, the tasks and their done state are still there.
- Adding a task like <b>hello</b> shows those characters as text, not bold.
Why textContent and Not innerHTML
Many CRUD tutorials build each item like this:
// Don't do this: the task text is parsed as HTML
li.innerHTML = `${task.text} <button onclick="deleteTask(${task.id})">Delete</button>`;Because the task text is parsed as HTML, a user who adds the task <img src=x onerror="alert(1)"> runs JavaScript on your page. In our test of this pattern the injected code ran immediately. In a single-user to-do app that's a curiosity; in any app that shows one user's text to another (comments, shared lists, admin panels) it's a cross-site scripting hole. Setting textContent — or using createElement as render() does — always treats the text as text.
CRUD with a REST API Using fetch
In a real app the data usually lives on a server. The operations map to HTTP methods: POST creates, GET reads, PUT or PATCH updates, DELETE removes. Here they are against the free DummyJSON todos API:
const API = "https://dummyjson.com/todos";
async function request(url, options = {}) {
const res = await fetch(url, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!res.ok) {
throw new Error(`${options.method ?? "GET"} ${url} failed: ${res.status}`);
}
return res.json();
}
// Create — POST
function createTodo(todo) {
return request(`${API}/add`, {
method: "POST",
body: JSON.stringify({ todo, completed: false, userId: 1 }),
});
}
// Read — GET
function getTodos(limit = 10) {
return request(`${API}?limit=${limit}`).then((data) => data.todos);
}
// Update — PUT (DummyJSON also accepts PATCH)
function updateTodo(id, changes) {
return request(`${API}/${id}`, {
method: "PUT",
body: JSON.stringify(changes),
});
}
// Delete — DELETE
function deleteTodo(id) {
return request(`${API}/${id}`, { method: "DELETE" });
}const todo = await createTodo("Write the report"); // { id, todo, completed, userId }
const todos = await getTodos(5); // array of todos
await updateTodo(1, { completed: true });
await deleteTodo(1); // { id: 1, isDeleted: true, ... }- One request() helper sets the JSON header and turns non-2xx responses into errors with the method, URL and status — fetch itself only rejects on network failures, not on 404 or 500.
- DummyJSON simulates writes: it returns the created, updated or deleted object but doesn't store the change. In our test, reading todo 1 after the PUT still showed completed: false. Your own API will persist them.
- To connect the task manager to an API, call these functions inside createTask, updateTask and deleteTask, and update the local array (and re-render) when the request succeeds.
Common Mistakes
| Mistake | What happens | Fix |
|---|---|---|
| Building items with innerHTML | User text runs as HTML/JS (XSS) | textContent or createElement |
| Button onclick without a form | Enter doesn't add a task | Wrap the input in a form and handle submit |
| Data only in a variable | Everything is lost on reload | Save to localStorage or a server |
| JSON.parse(localStorage...) without try/catch | One bad value crashes the app on load | Wrap it and fall back to [] |
| Date.now() as the only ID | Duplicate IDs when adding quickly | Add a random suffix or use the server's ID |
| Not checking res.ok | 404/500 responses look like success | Throw when !res.ok |
FAQs
What is a CRUD application?
An app that lets users Create, Read, Update and Delete records. A to-do list, a contact list and a product admin are all CRUD apps.
Can I build a CRUD app with only HTML, CSS and JavaScript?
Yes. This guide's task manager needs no server: it keeps data in an array and saves it to localStorage. You need a backend once data must be shared between users or devices.
How do I save data permanently?
In the browser, localStorage keeps data across reloads for that browser only (about 5 MB per site). For data that must follow the user or be shared, send it to a server with fetch and store it in a database. See our guide on cookies vs localStorage for the differences.
How do I do CRUD with fetch?
Use POST to create, GET to read, PUT or PATCH to update and DELETE to remove, send JSON with a Content-Type: application/json header, and check res.ok before using the response.
What's the difference between PUT and PATCH?
PUT replaces the whole record with what you send; PATCH changes only the fields you send. Many APIs accept either for partial updates, so check your API's documentation.
How do I add validation?
Use HTML attributes (required, maxlength) for basic checks, and trim and validate in JavaScript before saving. Always validate again on the server — client-side checks can be bypassed.
Should I use React or Vue for a CRUD app?
For a small app, plain JavaScript is enough and teaches the fundamentals. A framework helps once you have many screens and shared state; the CRUD logic — an array of records and functions that change it — stays the same.
Related Articles
View allAbout Muhaymin Bin Mehmood
Front-end Developer skilled in the MERN stack, experienced in web and mobile development. Proficient in React.js, Node.js, and Express.js, with a focus on client interactions, sales support, and high-performance applications.




