Build a Simple CRUD Application with JavaScript: Guide

Muhaymin Bin Mehmood

Muhaymin Bin Mehmood

·
7 min read
Published Dec 27, 2024
Updated Oct 8, 2026
How to Build a Simple CRUD Application with JavaScript: A Step-by-Step Guide Banner Image
11 min read
1,038 words
18 sections7 code blocks

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:

OperationIn this appJavaScript arrayREST API (HTTP)SQL
CreateAdd a taskpush()POSTINSERT
ReadShow the listmap() to renderGETSELECT
UpdateEdit or mark donemap() with changesPUT / PATCHUPDATE
DeleteRemove a taskfilter()DELETEDELETE

Step 1: Set Up the Project

Create a folder with three files:

Bash
crud-app/
├── index.html
├── style.css
└── script.js

Step 2: The HTML

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

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:

JavaScript
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:

JavaScript
// 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:

JavaScript
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" });
}
JavaScript
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

MistakeWhat happensFix
Building items with innerHTMLUser text runs as HTML/JS (XSS)textContent or createElement
Button onclick without a formEnter doesn't add a taskWrap the input in a form and handle submit
Data only in a variableEverything is lost on reloadSave to localStorage or a server
JSON.parse(localStorage...) without try/catchOne bad value crashes the app on loadWrap it and fall back to []
Date.now() as the only IDDuplicate IDs when adding quicklyAdd a random suffix or use the server's ID
Not checking res.ok404/500 responses look like successThrow 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.

No comments yet. Be the first to comment!

Leave a Comment

2000 characters remaining

Related Articles

View all
Muhaymin Bin Mehmood

About 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.