DOM Manipulation in Vanilla JavaScript
A modern guide to vanilla DOM manipulation: query selectors, creating and inserting nodes, event handling, delegation, and when a framework is actually worth it.
What you'll learn
- ✓Modern query selectors and how to choose between them
- ✓Creating, inserting, replacing, and removing nodes
- ✓Event listeners, options, and removal
- ✓Event delegation and why it scales
- ✓When vanilla DOM is enough and when to reach for a framework
Prerequisites
- •Familiarity with JavaScript functions
- •Comfort with arrays and iteration
For a long time, “vanilla DOM” was a polite way of saying “painful.” jQuery existed because the browser APIs were inconsistent and verbose. That world is gone. The modern DOM is concise, fast, and consistent across every browser people actually use. You can build a lot before you need a framework, and you should know how to, because every framework eventually drops you back here when something weird happens.
Selecting elements
There are five selectors worth knowing.
document.getElementById("user"); // returns one Element or null
document.getElementsByClassName("active"); // live HTMLCollection
document.getElementsByTagName("li"); // live HTMLCollection
document.querySelector(".user.active"); // first match, any CSS selector
document.querySelectorAll(".user"); // NodeList of all matches
In modern code, querySelector and querySelectorAll cover almost everything. They accept any CSS selector, which means you do not need to learn a second query language. The performance difference between getElementById and querySelector("#id") is real but irrelevant unless you are querying inside a hot loop.
One thing to know: querySelectorAll returns a static NodeList. It is a snapshot of the matches at the moment you called it. getElementsByClassName returns a live HTMLCollection that updates as the DOM changes. The static version is usually what you want.
To iterate, treat the NodeList like an array. Or convert it.
document.querySelectorAll(".item").forEach((el) => el.classList.add("read"));
const items = [...document.querySelectorAll(".item")];
items.map((el) => el.textContent);
The spread syntax pairs well with what you already do on arrays.
Reading and changing content
const el = document.querySelector("#title");
el.textContent = "New title"; // safe, sets text only
el.innerHTML = "<em>Title</em>"; // parses HTML, dangerous with untrusted input
el.innerText = el.innerText; // similar to textContent but layout-aware
Default to textContent. Reach for innerHTML only when you are inserting markup you control. Never assign untrusted strings to innerHTML. That is the original XSS vulnerability.
For attributes, use the matching property when one exists.
img.src = "/avatar.png"; // property
img.setAttribute("alt", "Me"); // attribute
link.href; // resolved URL
link.getAttribute("href"); // raw value
For classes, the classList API is small and pleasant:
el.classList.add("active");
el.classList.remove("hidden");
el.classList.toggle("open");
el.classList.contains("active");
For data attributes, the dataset property handles the prefix for you:
<div data-user-id="42"></div>
div.dataset.userId; // "42"
Creating and inserting nodes
The classic way to build a node tree is createElement followed by append:
const li = document.createElement("li");
li.textContent = "New item";
li.classList.add("item");
document.querySelector("ul").append(li);
append accepts multiple arguments and even strings, which it converts to text nodes automatically.
container.append(headingEl, "some text", iconEl);
For inserting at a specific position, insertAdjacentElement and insertAdjacentHTML are the precise tools:
target.insertAdjacentElement("beforebegin", node); // before target
target.insertAdjacentElement("afterbegin", node); // first child
target.insertAdjacentElement("beforeend", node); // last child
target.insertAdjacentElement("afterend", node); // after target
To remove a node, call remove() on it directly. To replace, use replaceWith.
oldEl.remove();
oldEl.replaceWith(newEl);
When you have many nodes to insert at once, build a DocumentFragment first. It is a lightweight container that the browser inserts in one operation, avoiding layout thrash.
const frag = document.createDocumentFragment();
for (const item of items) {
const li = document.createElement("li");
li.textContent = item.name;
frag.append(li);
}
document.querySelector("ul").append(frag);
One reflow instead of N. For a hundred items, you might not notice. For ten thousand, the difference is the user noticing the page froze.
Event listeners
addEventListener is the only event API you should use. Inline onclick is fine for prototypes, but it caps you at one handler per event per element and tangles markup with behavior.
button.addEventListener("click", (event) => {
console.log("clicked", event.target);
});
event is the source of all useful information: target, currentTarget, key, clientX, and a long tail of properties that depend on the event type. preventDefault() stops the browser’s default behavior. stopPropagation() stops the event from bubbling up.
To remove a listener, you need the exact same function reference you registered with. This is why inline arrow functions cannot be removed:
const onClick = () => console.log("hi");
btn.addEventListener("click", onClick);
btn.removeEventListener("click", onClick);
addEventListener also accepts an options object. Three flags matter.
once: trueremoves the listener after the first call. Handy for setup events.passive: truetells the browser the listener will not callpreventDefault. Required for smooth scroll on touch devices.signalties the listener to anAbortController. Callingcontroller.abort()removes the listener cleanly.
const controller = new AbortController();
btn.addEventListener("click", onClick, { signal: controller.signal });
// later
controller.abort(); // removes the listener
That last pattern is fantastic for components that mount and unmount. One controller cleans up every listener at once.
Event delegation
You almost never want one listener per row in a list. Instead, attach one listener to the parent and check what was clicked. This is event delegation.
document.querySelector("ul").addEventListener("click", (event) => {
const li = event.target.closest("li");
if (!li) return;
console.log("clicked", li.dataset.id);
});
closest walks up from the target until it finds a matching element, or returns null. It is the magic ingredient. Delegation works because events bubble up from the deepest target through every ancestor.
Why bother? Three reasons.
You can add and remove rows freely without touching listeners. New rows automatically work.
You use one function instead of N, which can matter for memory in large tables.
You can handle dozens of related actions in one handler:
table.addEventListener("click", (event) => {
const action = event.target.closest("[data-action]")?.dataset.action;
if (action === "edit") return edit(event);
if (action === "delete") return remove(event);
});
Mark up your buttons with data-action="edit" and the delegation handler does the routing. This is the closest vanilla DOM gets to a tiny event router.
Forms
Forms have their own helpers. FormData reads every named field for you.
form.addEventListener("submit", (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.target));
console.log(data);
});
Object.fromEntries paired with FormData is the cleanest way to grab a typed-ish object out of a form. Combine that with fetch and you have a working form submission in eight lines.
When to reach for a framework
You can build a lot with vanilla DOM. Static pages, sprinkled interactivity, even small dashboards. The honest question is when the framework starts paying for itself.
Reach for a framework when:
- You have lots of state that drives the UI, and you would otherwise be diffing the DOM by hand.
- Components need to be composed, reused, and tested in isolation.
- You want hot module reloading and strong tooling out of the box.
- The team is bigger than two people and you want a shared mental model.
Stick with vanilla when:
- The page is mostly static content with a few interactive widgets.
- Bundle size and time-to-interactive are the priority.
- You are inside a framework like Astro that uses small islands of interactivity. The DOM API is your friend here.
The best modern apps mix both. Use Astro or HTML for the shell, vanilla DOM for small enhancements, and reach for a heavier framework only where state demands it.
Wrap up
Vanilla DOM in 2026 is not the painful experience your senior coworkers remember. querySelector, classList, append, addEventListener, and closest cover almost every interaction you will write. Delegate events instead of attaching hundreds of listeners. Use AbortController signals to clean up. Build batches into DocumentFragment when you have many nodes. Reach for a framework only when the state model justifies it. Master the small surface area here and the frameworks you do use will feel less like magic and more like sensible automation.