Skip to main content
JavaScript beginner Lesson 18 of 24

DOM Manipulation in JavaScript

Learn how to select, create, modify, and remove DOM elements, handle events with delegation, and avoid common security pitfalls like XSS.

The Document Object Model (DOM) is the browser’s live representation of an HTML page as a tree of objects. JavaScript can read and modify this tree to create interactive experiences — updating content, responding to clicks, adding and removing elements dynamically. Every visible change you make to a web page from JavaScript goes through the DOM.

Selecting Elements

Before you can modify an element, you need a reference to it. querySelector and querySelectorAll accept any valid CSS selector and are the modern standard — they replaced older, more limited methods like getElementById and getElementsByClassName.

// querySelector returns the first matching element, or null if none found
const title = document.querySelector("h1");
const submitBtn = document.querySelector("#submit-btn");
const firstCard = document.querySelector(".card");
const emailInput = document.querySelector('input[type="email"]');

// querySelectorAll returns a static NodeList of all matches
const allCards = document.querySelectorAll(".card");
const navLinks = document.querySelectorAll("nav a");

// NodeList is array-like but not an array — use Array.from or spread to get array methods
const linkArray = Array.from(navLinks);
linkArray.forEach(link => link.classList.add("nav-item"));

// Spread operator works too
[...allCards].filter(card => card.dataset.featured === "true");

Creating and Inserting Elements

Building elements programmatically with createElement is safer than string-based HTML construction — you set properties and content explicitly, so there’s no risk of accidentally injecting HTML from user data.

// Create an element and configure it
const article = document.createElement("article");
article.className = "post-card";
article.dataset.postId = "42"; // sets data-post-id attribute

// Build content safely — textContent never parses HTML
const heading = document.createElement("h2");
heading.textContent = userProvidedTitle; // safe even if title contains < or >

const para = document.createElement("p");
para.textContent = userProvidedBody;

// Assemble the tree before inserting — one DOM insertion is faster than many
article.appendChild(heading);
article.appendChild(para);

// Several ways to insert into the document
const container = document.querySelector("#posts-container");
container.appendChild(article);              // append at the end
container.prepend(article);                  // insert at the beginning
container.insertBefore(article, container.firstChild); // before a specific sibling

// insertAdjacentElement gives explicit control over position
container.insertAdjacentElement("afterbegin", article); // first child
container.insertAdjacentElement("beforeend", article);  // last child

innerHTML vs textContent

This is one of the most important safety distinctions in frontend development. innerHTML interprets its value as HTML — useful for rendering formatted content, but dangerous with untrusted input because it executes any scripts or event handlers in the string.

const div = document.querySelector("#output");

// SAFE: textContent treats the value as plain text, escaping all special characters
div.textContent = userInput; // "<script>alert(1)</script>" is displayed as literal text

// DANGEROUS with user input: parses as HTML and will execute injected scripts
div.innerHTML = userInput; // XSS vulnerability if userInput is untrusted!

// SAFE for static, trusted HTML that you control
div.innerHTML = `
  <strong>Welcome back!</strong>
  <span class="badge">Pro</span>
`;

// If you genuinely need to render user-provided HTML, sanitize it first
import DOMPurify from "dompurify";
div.innerHTML = DOMPurify.sanitize(userProvidedHtml); // strips dangerous tags/attributes

Modifying Elements

Once you have a reference to an element, you can change its classes, attributes, data properties, and styles. Prefer classList methods over manipulating className as a string — they’re more composable and don’t accidentally remove unrelated classes.

const btn = document.querySelector("#action-btn");

// classList methods — composable, no string manipulation needed
btn.classList.add("active");
btn.classList.remove("disabled");
btn.classList.toggle("expanded");          // add if absent, remove if present
btn.classList.toggle("open", isOpen);      // set to boolean value directly
btn.classList.replace("old-class", "new-class");
console.log(btn.classList.contains("active")); // true/false

// Attributes — for ARIA, data attributes, and HTML attributes
btn.setAttribute("aria-pressed", "true");
btn.removeAttribute("disabled");
btn.getAttribute("data-action");           // read any attribute

// data-* attributes via dataset — automatically converts between camelCase and kebab-case
btn.dataset.userId = "123";               // sets data-user-id="123"
console.log(btn.dataset.userId);          // "123"

// Inline styles — use sparingly; prefer CSS classes when possible
btn.style.backgroundColor = "#0070f3";
btn.style.transform = "scale(1.05)";

Removing Elements

Removing elements cleanly is important when building dynamic UIs to avoid memory leaks from detached elements still referenced in JavaScript.

const staleCard = document.querySelector(".card.stale");

// Modern approach — remove the element directly
staleCard.remove();

// Or remove a child via its parent (older API, still valid)
const list = document.querySelector("#todo-list");
const item = document.querySelector("#todo-item-5");
list.removeChild(item);

// Remove all children efficiently — faster and cleaner than innerHTML = ""
list.replaceChildren(); // clears all children without parsing HTML

Event Listeners

Event listeners are how you respond to user interactions. The addEventListener API is flexible — you can control whether the listener fires once, whether it participates in scroll performance optimization, and you can always remove it later with a matching reference.

const button = document.querySelector("#submit");

function handleClick(event) {
  console.log("Clicked:", event.target);
  console.log("Coordinates:", event.clientX, event.clientY);
}

// Add listener
button.addEventListener("click", handleClick);

// Remove listener — must pass the same function reference (not a new arrow function)
button.removeEventListener("click", handleClick);

// One-time listener — automatically removed after the first invocation
button.addEventListener("click", handleClick, { once: true });

// Passive listener — tells the browser you won't call preventDefault, improving scroll perf
window.addEventListener("scroll", onScroll, { passive: true });

Event Delegation

Instead of attaching a listener to every item in a list, attach one to the parent. Events bubble up through the DOM tree, so a click on any child reaches the parent listener. This approach uses less memory, works automatically for elements added to the DOM after the listener is registered, and requires only one cleanup call when tearing down the component.

const todoList = document.querySelector("#todo-list");

// One listener handles all clicks, regardless of how many items exist
todoList.addEventListener("click", (event) => {
  // closest() walks up the DOM tree to find the matching ancestor
  const deleteBtn = event.target.closest("[data-action='delete']");
  const completeBtn = event.target.closest("[data-action='complete']");

  if (deleteBtn) {
    const item = deleteBtn.closest(".todo-item");
    item.remove();
  }

  if (completeBtn) {
    const item = completeBtn.closest(".todo-item");
    item.classList.toggle("done");
  }
});

// New items added dynamically later automatically benefit from this listener

A Complete Todo List Example

This example combines all the techniques above into a working feature: safe element creation, form handling, and event delegation.

const form = document.querySelector("#todo-form");
const input = document.querySelector("#todo-input");
const list = document.querySelector("#todo-list");

// Add a new todo on form submit
form.addEventListener("submit", (event) => {
  event.preventDefault(); // stop the browser from reloading the page

  const text = input.value.trim();
  if (!text) return;

  // Build elements programmatically — no innerHTML, no XSS risk
  const li = document.createElement("li");
  li.className = "todo-item";

  const span = document.createElement("span");
  span.textContent = text; // safe — user text is never parsed as HTML

  const deleteBtn = document.createElement("button");
  deleteBtn.textContent = "Delete";
  deleteBtn.dataset.action = "delete";
  deleteBtn.setAttribute("aria-label", `Delete: ${text}`); // accessibility

  li.appendChild(span);
  li.appendChild(deleteBtn);
  list.appendChild(li);

  input.value = "";
  input.focus();
});

// Handle all list interactions with one delegated listener
list.addEventListener("click", (event) => {
  const btn = event.target.closest("button[data-action]");
  if (!btn) return; // click was not on an action button

  const item = btn.closest(".todo-item");

  if (btn.dataset.action === "delete") {
    item.remove();
  }
});

preventDefault and stopPropagation

These two methods are often confused. They control different things: one stops the browser’s built-in behavior, the other stops the event from traveling further up the DOM tree.

// preventDefault: stop the browser's default action for this event
document.querySelector("a.ajax-link").addEventListener("click", (event) => {
  event.preventDefault(); // don't navigate to the href
  loadContentAjax(event.target.href);
});

document.querySelector("#my-form").addEventListener("submit", (event) => {
  event.preventDefault(); // don't submit the form / reload the page
  validateAndSubmit(event.target);
});

// stopPropagation: stop the event from bubbling to parent listeners
document.querySelector(".modal").addEventListener("click", (event) => {
  event.stopPropagation(); // click inside the modal stays inside — doesn't close it
});

document.querySelector(".modal-overlay").addEventListener("click", () => {
  closeModal(); // only fires when clicking the overlay directly, not the modal content
});

Key Takeaways

  • Use querySelector / querySelectorAll with CSS selectors for all element lookups.
  • Always use textContent for user-supplied text; never use innerHTML with untrusted data.
  • Use event delegation — one listener on a parent — instead of listeners on each child.
  • event.preventDefault() blocks browser defaults; event.stopPropagation() blocks bubbling.
  • Use classList methods instead of manipulating className strings directly.

Frequently Asked Questions

Why is innerHTML dangerous with user-supplied content?
innerHTML parses its value as HTML, so injecting user input can introduce script tags or event handler attributes that execute arbitrary JavaScript — a cross-site scripting (XSS) attack. Use textContent to insert plain text safely, or sanitize HTML with a library like DOMPurify before using innerHTML.
What is event delegation and why is it better than attaching listeners to every element?
Event delegation attaches a single listener to a parent element and uses event.target to identify which child triggered the event. It is more memory-efficient, works for dynamically added elements, and is simpler to remove when cleaning up.
What is the difference between stopPropagation and preventDefault?
preventDefault() stops the browser's default action (e.g., form submission, link navigation) but the event still bubbles up. stopPropagation() stops the event from bubbling to parent elements but does not affect the default action. They are independent and can be used together.