Even in the age of React and Vue, understanding raw DOM manipulation is essential. It's what frameworks do under the hood, it's how you build web components, and it's the only option for vanilla JavaScript projects. Modern DOM APIs are surprisingly elegant — you might not need jQuery after all.

The DOM (Document Object Model) is the browser's live, in-memory representation of your HTML. Every tag becomes a node object that JavaScript can read, change, move, or delete — and the browser re-renders the page to match. That last part is the catch: DOM operations can trigger style recalculation, layout, and paint, which makes careless DOM code the number one cause of janky interfaces. This guide covers the modern APIs for selecting, modifying, creating, and listening to elements, plus the performance habits that keep everything smooth.

Selecting Elements

Before you can change anything, you need a reference to it. querySelector and querySelectorAll accept any valid CSS selector, which means the selector skills you already have transfer directly — classes, attribute selectors, pseudo-classes like :first-of-type, and combinators all work. getElementById is still slightly faster and is the natural choice when an element has a unique ID, but in practice the difference only matters in tight loops.

Modern Selectors
// Single element
const header = document.querySelector('.header');
const submitBtn = document.getElementById('submit-btn');
const firstArticle = document.querySelector('article:first-of-type');

// Multiple elements (returns NodeList)
const cards = document.querySelectorAll('.card');
const links = document.querySelectorAll('nav a');

// Iterate NodeList
cards.forEach(card => {
  card.classList.add('loaded');
});

// Convert to array for full array methods
const cardArray = [...document.querySelectorAll('.card')];
const visibleCards = cardArray.filter(c => !c.hidden);

Two details trip people up here. First, querySelectorAll returns a static NodeList — a snapshot taken at call time. If you add matching elements later, the list does not update (unlike the live HTMLCollection returned by older methods such as getElementsByClassName). Second, a NodeList supports forEach but not map, filter, or reduce; spread it into a real array first, as shown above, whenever you need those. Also remember that querySelector returns null when nothing matches — guard against that before calling methods on the result, or you'll hit the classic "Cannot read properties of null" error.

Modifying Elements

Once you have an element, you can change its text, attributes, classes, and styles. The most important distinction in this whole section is textContent versus innerHTML. textContent treats everything as plain text, so it is immune to script injection; innerHTML parses the string as HTML, which means any user-supplied data placed there can execute markup you never intended — the root cause of most DOM-based XSS vulnerabilities. The rule is simple: use innerHTML only with strings you fully control, and textContent for anything that originated from a user, a URL, or an API.

Content, Attributes & Classes
const el = document.querySelector('.hero-title');

// Text content (safe — no HTML injection)
el.textContent = 'Hello, World!';

// HTML content (⚠️ XSS risk with user input)
el.innerHTML = '<strong>Hello</strong>, World!';

// Attributes
el.setAttribute('role', 'heading');
el.getAttribute('data-id');
el.removeAttribute('hidden');

// Dataset (data-* attributes)
el.dataset.userId = '123';    // Sets data-user-id="123"
console.log(el.dataset.userId); // "123"

// Classes
el.classList.add('active', 'visible');
el.classList.remove('loading');
el.classList.toggle('dark-mode');
el.classList.contains('active');  // true/false
el.classList.replace('old-class', 'new-class');

// Inline styles
el.style.color = '#6366f1';
el.style.setProperty('--accent', '#22d3ee');

Prefer classList over manipulating the className string directly — it handles duplicates and whitespace for you, and toggle plus contains cover most state-switching needs in one call. Likewise, reach for dataset instead of getAttribute('data-…'): it converts kebab-case attribute names to camelCase properties automatically and reads much more naturally. For styling, setting classes is almost always better than writing inline styles, because inline styles have high specificity and scatter presentation logic through your JavaScript; save el.style for genuinely dynamic values like a computed position or a CSS custom property.

Creating & Inserting Elements

Building elements in JavaScript is how you render dynamic data without a framework. The modern insertion methods — before, after, prepend, append, and replaceWith — are far more ergonomic than the old insertBefore(newNode, referenceNode) dance, and they accept plain strings as well as nodes. One thing to internalize: a DOM node can only exist in one place. If you append an element that is already in the document, the browser moves it rather than copying it — use cloneNode(true) when you actually want a copy.

Dynamic Element Creation
// Create an element
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
  <h3>${title}</h3>
  <p>${description}</p>
`;

// Insert into DOM
container.appendChild(card);                    // At the end
container.prepend(card);                        // At the beginning
container.insertBefore(card, referenceElement); // Before a specific element

// Modern insertion methods
element.before(newElement);    // Before the element
element.after(newElement);     // After the element
element.replaceWith(newElement); // Replace the element

// Remove from DOM
element.remove();

// Clone an element
const clone = template.cloneNode(true); // true = deep clone

When inserting many elements at once, avoid appending them to the live document one by one — every insertion can force the browser to recalculate layout. Instead, build the elements inside a DocumentFragment and append the fragment in a single operation. The fragment itself never appears in the DOM; only its children do, so you get one reflow instead of dozens.

Event Handling

addEventListener is the only event API you should be using — it allows multiple listeners on the same element, supports options like { once: true } and { passive: true }, and can be removed later. The classic mistake with removal is passing a fresh arrow function to removeEventListener: because two separately created functions are never equal, the listener silently stays attached. Keep a reference to the exact same named function for both calls, as the example below shows.

addEventListener
const button = document.querySelector('#submit');

// Add event listener
button.addEventListener('click', (event) => {
  event.preventDefault();
  console.log('Clicked!', event.target);
});

// Remove event listener (must use named function)
function handleClick(e) { /* ... */ }
button.addEventListener('click', handleClick);
button.removeEventListener('click', handleClick);

// Common events
element.addEventListener('click', handler);
element.addEventListener('input', handler);     // Real-time input changes
element.addEventListener('change', handler);    // Input loses focus
element.addEventListener('submit', handler);    // Form submission
element.addEventListener('keydown', handler);   // Key pressed
element.addEventListener('scroll', handler);    // Scrolling
element.addEventListener('load', handler);      // Resource loaded

Also know the difference between event.target (the innermost element that was actually interacted with) and event.currentTarget (the element the listener is attached to). And use preventDefault() deliberately: calling it on a form's submit event stops the page from reloading so you can handle the submission with JavaScript, but calling it reflexively on every event can break native behavior like link navigation and checkbox toggling.

Event Delegation

Instead of adding event listeners to every child element, add one listener to the parent and use event.target to determine which child was clicked. This is more performant and works with dynamically added elements.

Delegation works because most events bubble: a click on a nested element travels up through every ancestor, so a single listener on the container sees clicks from all of its descendants — including ones added after the listener was attached. The closest() call in the pattern below is the key detail: the click might land on an icon or heading inside the card, and closest('.card') walks up from the actual target to find the card element you care about. Note that a few events (focus, blur, and mouseenter among them) do not bubble; for focus delegation, use the bubbling focusin and focusout events instead.

Event Delegation Pattern
// ❌ Bad — one listener per item
document.querySelectorAll('.card').forEach(card => {
  card.addEventListener('click', handleCardClick);
});

// ✅ Good — one listener on the container
document.querySelector('.card-grid').addEventListener('click', (e) => {
  const card = e.target.closest('.card');
  if (!card) return; // Click wasn't on a card

  const id = card.dataset.id;
  handleCardClick(id);
});

Template Element

The <template> element solves an awkward problem: you want to define reusable markup in HTML (where it is readable and maintainable) rather than as strings in JavaScript, but you don't want that markup rendered until it is needed. Content inside a template is parsed but inert — images don't load, scripts don't run, and nothing is displayed. When you need an instance, clone template.content, fill in the dynamic parts with textContent (safe against injection), and append the clone. This pattern is dramatically cleaner than concatenating HTML strings and is the foundation of how web components render their shadow DOM.

Using <template>
<!-- HTML template (not rendered) -->
<template id="card-template">
  <div class="card">
    <h3 class="card-title"></h3>
    <p class="card-desc"></p>
  </div>
</template>

<script>
const template = document.getElementById('card-template');

function addCard(title, desc) {
  const clone = template.content.cloneNode(true);
  clone.querySelector('.card-title').textContent = title;
  clone.querySelector('.card-desc').textContent = desc;
  document.querySelector('.grid').appendChild(clone);
}
</script>

Performance Tips

Most DOM performance problems come down to one thing: forcing the browser to do layout work more often than necessary. Reading a layout property like offsetHeight forces the browser to flush any pending style changes so it can give you an accurate answer; alternating reads and writes in a loop therefore triggers a full layout on every iteration — a pattern known as layout thrashing. The fix is to batch: read everything first, then write everything.

  • Batch DOM updates — use DocumentFragment or build HTML strings before inserting.
  • Use event delegation instead of listeners on every child.
  • Avoid layout thrashing — don't read layout properties (offsetHeight) then immediately write (style.height) in a loop.
  • Use requestAnimationFrame for visual updates.
  • Debounce scroll and resize handlers — they fire dozens of times per second.

requestAnimationFrame deserves special mention: it schedules your callback to run right before the browser's next paint, so visual updates land exactly once per frame instead of racing ahead of the display's refresh rate. For scroll-triggered effects, consider IntersectionObserver instead of a scroll listener entirely — it tells you when an element enters or leaves the viewport without any per-scroll JavaScript cost.

Common Pitfalls

  • Running scripts before the DOM exists. A <script> in the <head> executes before the body is parsed, so querySelector returns null. Use defer, place scripts at the end of <body>, or wait for the DOMContentLoaded event.
  • Using innerHTML += in a loop. Each assignment re-parses the container's entire HTML and destroys existing event listeners and form state. Build the full string once, or use element creation with a fragment.
  • Leaking listeners. If you remove elements but keep references to them (in a closure or a global array), their listeners and subtree stay in memory. Remove listeners you no longer need, or attach them via delegation so cleanup is automatic.
  • Treating a static NodeList as live. After inserting new elements, re-query if you need an up-to-date collection.

Where to Go from Here

The APIs in this guide cover the vast majority of day-to-day DOM work. Once they feel natural, two follow-ups are worth your time. First, explore MutationObserver, which lets you react to DOM changes made by other code — invaluable for browser extensions and third-party-widget integration. Second, try building a small interactive component (an accordion, a tabbed panel, a filterable list) with nothing but these primitives. You'll gain a concrete feel for what React and Vue are abstracting away, and you'll be far better equipped to debug performance issues in any framework, because under every virtual DOM diff is exactly the kind of real DOM mutation described here.

Try Our Free JavaScript Tools

Minify and beautify your JavaScript and HTML.