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/querySelectorAllwith CSS selectors for all element lookups. - Always use
textContentfor user-supplied text; never useinnerHTMLwith 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
classListmethods instead of manipulatingclassNamestrings directly.