March 26, 20265 min read

DOM Manipulation in JavaScript — The Stuff You Actually Need

querySelector, createElement, event delegation, classList, data attributes, innerHTML vs textContent, and performance tips for real DOM work.

javascript dom web development events performance
Ad 336x280

React, Vue, and Svelte exist because manual DOM manipulation gets painful at scale. But here's the thing — you still need to know how the DOM works. Frameworks abstract it, they don't replace it. And for small scripts, browser extensions, vanilla projects, or debugging why your framework renders weird, raw DOM skills pay off constantly.

Selecting elements

Forget getElementById and getElementsByClassName. Use querySelector and querySelectorAll for everything:

const header = document.querySelector('h1');            // first match
const buttons = document.querySelectorAll('.btn');       // all matches (NodeList)
const emailInput = document.querySelector('#email');     // by ID
const activeItem = document.querySelector('li.active');  // compound selector
querySelectorAll returns a NodeList, not an array. You can forEach over it, but if you need map or filter, convert it:
const items = [...document.querySelectorAll('.item')];
const visible = items.filter(el => !el.classList.contains('hidden'));

Creating and inserting elements

const card = document.createElement('div');
card.className = 'card';
card.textContent = 'New card';

document.querySelector('.container').appendChild(card);

Other insertion methods that are more flexible than appendChild:

parent.prepend(element);           // first child
parent.append(element);            // last child (like appendChild but accepts strings too)
element.before(otherElement);      // sibling before
element.after(otherElement);       // sibling after
element.replaceWith(newElement);   // swap it out

These are all relatively modern and work in every current browser.

innerHTML vs textContent — this matters

element.textContent = userInput;  // safe — treats everything as text
element.innerHTML = userInput;    // DANGEROUS — parses as HTML

If userInput is , innerHTML will execute that script. This is a cross-site scripting (XSS) vulnerability. Use textContent for user-provided data. Always.

When you legitimately need to insert HTML you control:

container.innerHTML = '<div class="card"><h2>Title</h2><p>Body</p></div>';

This is fine when the HTML is hardcoded or built from trusted data. Just never interpolate user input into it.

classList

Way cleaner than manipulating className strings:

element.classList.add('active');
element.classList.remove('hidden');
element.classList.toggle('expanded');
element.classList.contains('disabled');  // returns boolean

// Multiple at once
element.classList.add('fade-in', 'visible');
element.classList.remove('fade-out', 'hidden');

Data attributes

Store arbitrary data on elements without inventing custom attributes:

<button data-user-id="42" data-action="delete">Delete User</button>
const btn = document.querySelector('button');
btn.dataset.userId;   // "42" (note: camelCase, not kebab-case)
btn.dataset.action;   // "delete"

// Set them too
btn.dataset.loading = 'true';

Data attributes are strings. Parse them if you need numbers or booleans.

Event delegation

Adding an event listener to every list item is wasteful and breaks when new items are added dynamically. Instead, listen on the parent:

document.querySelector('.todo-list').addEventListener('click', (e) => {
  const item = e.target.closest('.todo-item');
  if (!item) return;  // clicked something else

const id = item.dataset.id;
toggleTodo(id);
});

e.target is what was actually clicked. closest() walks up the DOM tree to find the matching ancestor. This pattern handles dynamically added items automatically because the listener is on the parent, not on individual children.

Event delegation is one of those things that feels like a trick at first but becomes second nature fast. It's also how jQuery's .on() worked under the hood.

Removing elements and listeners

element.remove();  // removes from DOM

// Event listeners — use named functions if you need to remove them
function handleClick(e) { / ... / }
btn.addEventListener('click', handleClick);
btn.removeEventListener('click', handleClick); // same function reference required

// Or use AbortController (modern approach)
const controller = new AbortController();
btn.addEventListener('click', handleClick, { signal: controller.signal });
// Later:
controller.abort(); // removes the listener

The AbortController approach is nice because you can remove multiple listeners at once with a single abort.

Performance: batch your DOM writes

The DOM is slow because every change can trigger layout recalculation ("reflow"). Reading layout properties (offsetHeight, getBoundingClientRect()) after writing forces the browser to recalculate immediately. This pattern kills performance:

// BAD — triggers reflow on every iteration
items.forEach(item => {
  item.style.width = '100px';        // write
  const height = item.offsetHeight;  // read (forces reflow!)
  item.style.height = height + 'px'; // write
});

Batch reads and writes separately:

// GOOD — one reflow
const heights = items.map(item => item.offsetHeight);  // all reads first
items.forEach((item, i) => {
  item.style.width = '100px';
  item.style.height = heights[i] + 'px';
});  // all writes together

DocumentFragment for bulk inserts

When adding many elements, build them off-DOM first:

const fragment = document.createDocumentFragment();

for (const user of users) {
const li = document.createElement('li');
li.textContent = user.name;
fragment.appendChild(li);
}

document.querySelector('.user-list').appendChild(fragment);
// One DOM insertion instead of hundreds

requestAnimationFrame for visual updates

If you're animating or updating the DOM in response to scroll/resize events, throttle with requestAnimationFrame:

let ticking = false;

window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
updateHeaderOnScroll();
ticking = false;
});
ticking = true;
}
});

This ensures your updates sync with the browser's repaint cycle (~60fps) instead of firing hundreds of times per second.

Why frameworks exist (but this still matters)

Manual DOM manipulation has real problems at scale: keeping the UI in sync with state is error-prone, you end up with spaghetti event listeners, and it's hard to reason about what the page looks like at any given moment. That's exactly what React's virtual DOM and Vue's reactivity system solve.

But understanding the real DOM means:


  • You can debug framework rendering issues

  • You can write efficient vanilla JS when a framework is overkill

  • You understand what your framework is doing under the hood

  • Browser extensions, bookmarklets, and userscripts are pure DOM


Build interactive DOM projects at CodeUp — the hands-on practice makes these patterns stick way better than copy-pasting snippets.

Ad 728x90