Skip to main content
JavaScript · lesson 18 of 24
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.

By SkillByExample · Updated

The DOM (Document Object Model) is the tree of objects a browser builds from HTML, and the API JavaScript uses to read and change the page. The examples below run in Node against jsdom, a standards-based DOM implementation, so every output block is a real run — npm install jsdom once, then node file.mjs. The first two lines of each example just create a document; everything after them is exactly what you’d type in a browser, where document already exists.

Selecting Elements

querySelector returns the first element matching a CSS selector (or null); querySelectorAll returns every match as a NodeList.

import { JSDOM } from "jsdom";
const { document } = new JSDOM(`
  <nav><a href="/">Home</a><a href="https://example.com/blog">Blog</a></nav>
  <div class="card" data-featured="true">A</div>
  <div class="card">B</div>
  <div class="card" data-featured="true">C</div>`).window;

console.log(document.querySelector(".card").textContent);
console.log(document.querySelector("#missing"));

const cards = document.querySelectorAll(".card");
console.log(cards.length, cards.constructor.name, Array.isArray(cards));
console.log(typeof cards.forEach, typeof cards.filter);

const featured = [...cards].filter((c) => c.dataset.featured === "true");
console.log(featured.map((c) => c.textContent));
console.log([...document.querySelectorAll("nav a")].map((a) => a.getAttribute("href")));
$ node select.mjs
A
null
3 NodeList false
function undefined
[ 'A', 'C' ]
[ '/', 'https://example.com/blog' ]

A missing element is null, not an exception — so document.querySelector("#missing").textContent would throw a TypeError one step later. A NodeList has forEach but not filter or map; spread it (or Array.from) to get array methods. The list is a static snapshot: elements added later don’t appear in it.

Creating and Inserting Elements

createElement plus textContent builds nodes without ever parsing a string as HTML.

import { JSDOM } from "jsdom";
const { document } = new JSDOM(`<section id="posts"><p>existing</p></section>`).window;

const userTitle = "<img src=x onerror=alert(1)> Hello";

const article = document.createElement("article");
article.className = "post-card";
article.dataset.postId = "42";
const heading = document.createElement("h2");
heading.textContent = userTitle;
article.append(heading);

const posts = document.querySelector("#posts");
posts.append(article);
console.log(posts.innerHTML);

posts.prepend(article);
console.log(posts.children.length, posts.firstElementChild.tagName);
$ node create.mjs
<p>existing</p><article class="post-card" data-post-id="42"><h2>&lt;img src=x onerror=alert(1)&gt; Hello</h2></article>
2 ARTICLE

The hostile title was escaped to &lt;img …&gt; — it’s text inside an <h2>, not an element. dataset.postId became the attribute data-post-id. Then prepend moved the article to the front rather than copying it: a node can only be in one place, so the section still has two children. Build a subtree fully before inserting it, so the live page is touched once.

innerHTML vs textContent

innerHTML parses its value as HTML; textContent treats it as plain text. With untrusted input that difference is an XSS vulnerability.

import { JSDOM } from "jsdom";
const { window } = new JSDOM(`<div id="a"></div><div id="b"></div>`, { runScripts: "dangerously" });
const { document } = window;
window.stolen = [];

const userInput = `<button onclick="stolen.push('cookie=' + document.cookie)">Win a prize</button>`;

document.querySelector("#a").textContent = userInput;
document.querySelector("#b").innerHTML = userInput;

console.log("#a children:", document.querySelector("#a").children.length);
console.log("#b children:", document.querySelector("#b").children.length);

document.querySelector("#b button").click();
console.log("attacker code ran:", window.stolen.length === 1);

document.querySelector("#b").innerHTML = `<script>stolen.push("script")</script>`;
console.log("script tag ran:", window.stolen.includes("script"));
$ node xss.mjs
#a children: 0
#b children: 1
attacker code ran: true
script tag ran: false

textContent produced zero child elements — the markup is just characters on screen. innerHTML built a real <button>, and clicking it executed the attacker’s inline handler. The last two lines explain why XSS payloads use onclick/onerror attributes rather than <script>: the HTML spec says scripts inserted through innerHTML never run, but event-handler attributes do. (runScripts: "dangerously" tells jsdom to behave like a browser and execute page script; it’s off by default.) If you must render user-supplied HTML, pass it through a sanitizer such as DOMPurify first.

Modifying Classes, Attributes and Data

classList edits one class at a time without clobbering the others; dataset maps data-* attributes to camelCase properties.

import { JSDOM } from "jsdom";
const { document } = new JSDOM(`<button id="btn" class="btn disabled" data-action="save">Save</button>`).window;
const btn = document.querySelector("#btn");

btn.classList.add("active");
btn.classList.remove("disabled");
btn.classList.toggle("expanded");
btn.classList.toggle("expanded");
btn.classList.toggle("open", 1 > 0);
console.log(btn.className, btn.classList.contains("active"));

btn.setAttribute("aria-pressed", "true");
btn.dataset.userId = 123;
console.log(btn.dataset.action, typeof btn.dataset.userId);

btn.style.backgroundColor = "#0070f3";
console.log(btn.outerHTML);
$ node modify.mjs
btn active open true
save string
<button id="btn" class="btn active open" data-action="save" aria-pressed="true" data-user-id="123" style="background-color: rgb(0, 112, 243);">Save</button>

Toggling expanded twice left it off; the two-argument toggle forces the state from a boolean. dataset.userId = 123 came back as the string "123" — attributes are always strings, so convert with Number() when reading. The final outerHTML shows every change landed as a plain HTML attribute, including the camelCase userId → data-user-id and the colour normalised to rgb(…).

Removing Elements

el.remove() detaches an element; replaceChildren() with no arguments clears a container.

import { JSDOM } from "jsdom";
const { document } = new JSDOM(`
  <ul id="list"><li class="stale">old</li><li id="keep">keep</li><li>third</li></ul>`).window;
const list = document.querySelector("#list");

const stale = document.querySelector(".stale");
stale.remove();
console.log(list.children.length, stale.isConnected, stale.textContent);

list.replaceChildren();
console.log(list.children.length, list.innerHTML === "");

list.append(stale);
console.log(list.innerHTML);
$ node remove.mjs
2 false old
0 true
<li class="stale">old</li>

A removed element is detached, not destroyed: isConnected is false but the object and its text still exist, and it can be inserted again. That’s also how DOM memory leaks happen — a variable (or a closure) still pointing at a removed node keeps its whole subtree alive.

Event Listeners

addEventListener registers a handler; removeEventListener needs the same function reference to remove it.

import { JSDOM } from "jsdom";
const { document } = new JSDOM(`<button id="submit">Go</button>`).window;
const button = document.querySelector("#submit");
const log = [];

function handleClick(event) { log.push(`named:${event.type}`); }
button.addEventListener("click", handleClick);
button.addEventListener("click", () => log.push("arrow"));
button.addEventListener("click", () => log.push("once"), { once: true });

button.click();
button.removeEventListener("click", handleClick);
button.removeEventListener("click", () => log.push("arrow"));
button.click();

console.log(log);
$ node listeners.mjs
[ 'named:click', 'arrow', 'once', 'arrow' ]

The first click ran all three handlers in registration order. The second click ran only arrow: the named handler was removed, the once handler removed itself, but removeEventListener with a new arrow function did nothing — it isn’t the same function object as the one registered. Keep a reference (or use an AbortController signal) for anything you’ll need to unregister.

Event Delegation

Events bubble from the target up through its ancestors, so one listener on a parent can handle clicks on every child — including children added later.

import { JSDOM } from "jsdom";
const { document } = new JSDOM(`<ul id="todos"></ul>`).window;
const list = document.querySelector("#todos");

list.addEventListener("click", (event) => {
  const btn = event.target.closest("button[data-action]");
  if (!btn) return;
  const item = btn.closest("li");
  if (btn.dataset.action === "done") item.classList.toggle("done");
  if (btn.dataset.action === "delete") item.remove();
});

for (const text of ["milk", "eggs"]) {
  const li = document.createElement("li");
  li.append(text);
  for (const action of ["done", "delete"]) {
    const b = document.createElement("button");
    b.dataset.action = action;
    b.innerHTML = `<span>${action}</span>`;
    li.append(b);
  }
  list.append(li);
}

list.querySelector("li:first-child [data-action=done] span").click();
list.querySelector("li:last-child [data-action=delete]").click();
console.log(list.outerHTML);
$ node delegate.mjs
<ul id="todos"><li class="done">milk<button data-action="done"><span>done</span></button><button data-action="delete"><span>delete</span></button></li></ul>

The listener was attached before any <li> existed and still handled both. The first click landed on the <span> inside a button — event.target was the span, which is why the handler uses closest() to walk up to the button rather than checking event.target directly. One listener instead of four, and nothing to clean up per item.

preventDefault and stopPropagation

preventDefault() cancels the browser’s default action (following a link, submitting a form). stopPropagation() stops the event bubbling to ancestors. They’re independent.

import { JSDOM } from "jsdom";
const { window } = new JSDOM(`
  <div class="overlay"><div class="modal"><a href="https://example.com/next">Next</a></div></div>`, { url: "https://example.com/" });
const { document } = window;

document.querySelector(".overlay").addEventListener("click", () => console.log("overlay: close modal"));
document.querySelector(".modal").addEventListener("click", (e) => {
  console.log("modal: stopPropagation");
  e.stopPropagation();
});
document.querySelector("a").addEventListener("click", (e) => {
  e.preventDefault();
  console.log("link: preventDefault, defaultPrevented =", e.defaultPrevented);
});

document.querySelector("a").click();
console.log("still on", window.location.pathname);
console.log("--- click directly on the overlay");
document.querySelector(".overlay").click();
$ node prevent.mjs
link: preventDefault, defaultPrevented = true
modal: stopPropagation
still on /
--- click directly on the overlay
overlay: close modal

The link’s handler cancelled navigation — but the event kept bubbling to .modal, because preventDefault has nothing to do with propagation. .modal then stopped it, so .overlay never heard the click from inside the modal. Clicking the overlay itself does reach its handler. This is the classic “click outside to close” modal.

Key Takeaways

  • querySelector returns null when nothing matches; querySelectorAll returns a static NodeList — spread it for array methods.
  • Use textContent for user-supplied text; innerHTML with untrusted input runs attacker handlers.
  • Attributes and dataset values are always strings.
  • Removing a listener needs the original function reference.
  • Delegate: one listener on a parent plus closest() handles current and future children.
  • preventDefault cancels default actions; stopPropagation stops bubbling — they’re independent.

Practice

1. Render a list of user names into a <ul> safely, one <li> each, using a single insertion.
import { JSDOM } from "jsdom";
const { document } = new JSDOM(`<ul id="users"></ul>`).window;

const names = ["Alice", "<b>Bob</b>", "Carol"];
const items = names.map((name) => {
  const li = document.createElement("li");
  li.textContent = name;
  return li;
});
document.querySelector("#users").replaceChildren(...items);
console.log(document.querySelector("#users").innerHTML);
$ node p1.mjs
<li>Alice</li><li>&lt;b&gt;Bob&lt;/b&gt;</li><li>Carol</li>

replaceChildren(...items) inserts all the items in one call, and textContent escaped Bob’s markup.

2. Use an AbortController to remove two listeners at once.
import { JSDOM } from "jsdom";
const { window } = new JSDOM(`<button>Go</button>`);
const button = window.document.querySelector("button");

const controller = new window.AbortController();
button.addEventListener("click", () => console.log("click A"), { signal: controller.signal });
button.addEventListener("click", () => console.log("click B"), { signal: controller.signal });

button.click();
controller.abort();
button.click();
console.log("after abort: silent");
$ node p2.mjs
click A
click B
after abort: silent

Aborting the signal removes every listener registered with it — no function references needed, which makes it ideal for anonymous handlers and component teardown.

3. Log the order in which a click travels from a <span> up through its parent <p> and <div>, including a capture-phase listener on the div.
import { JSDOM } from "jsdom";
const { document } = new JSDOM(`<div><p><span>hi</span></p></div>`).window;
const order = [];
for (const tag of ["div", "p", "span"]) {
  document.querySelector(tag).addEventListener("click", () => order.push(`bubble:${tag}`));
}
document.querySelector("div").addEventListener("click", () => order.push("capture:div"), { capture: true });

document.querySelector("span").click();
console.log(order.join(" -> "));
$ node p3.mjs
capture:div -> bubble:span -> bubble:p -> bubble:div

Capture listeners run on the way down, before the target; normal listeners run on the way back up, innermost first.

4. Read data-price from each product and total it — watch the type.
import { JSDOM } from "jsdom";
const { document } = new JSDOM(`
  <li data-price="19.5">A</li><li data-price="5">B</li><li data-price="0.5">C</li>`).window;
const products = [...document.querySelectorAll("li")];

const wrong = products.reduce((sum, li) => sum + li.dataset.price, 0);
const right = products.reduce((sum, li) => sum + Number(li.dataset.price), 0);
console.log(wrong);
console.log(right);
$ node p4.mjs
019.550.5
25

0 + "19.5" is string concatenation, so the “total” became a string of glued digits. Number() fixes it.

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.