Skip to content
Home
JavaScript DOM Manipulation: Complete Guide

JavaScript DOM Manipulation: Complete Guide

JavaScript JavaScript 8 min read 1698 words Beginner ExcellentWiki Editorial Team

The Document Object Model (DOM) is JavaScript’s interface to HTML documents. It represents the page as a tree of nodes that can be queried, modified, and animated. This guide covers the essential DOM APIs and patterns for modern web development.

Selecting Elements

// Single element (first match)
const header = document.querySelector('.header');        // CSS selector
const nav = document.getElementById('navigation');       // by ID

// Multiple elements (static NodeList)
const buttons = document.querySelectorAll('.btn');       // CSS selector

// Multiple elements (live HTMLCollection)
const divs = document.getElementsByTagName('div');        // by tag
const items = document.getElementsByClassName('item');   // by class

// Contextual queries
const container = document.querySelector('.container');
const child = container.querySelector('.child');
const allChildren = container.querySelectorAll('.child');

querySelectorAll returns a static NodeList — it does not update when the DOM changes. getElementsBy* returns a live collection that reflects DOM mutations automatically. Prefer querySelectorAll for performance and consistency.

Traversal

const element = document.querySelector('.target');

// Parent
const parent = element.parentElement;           // parent element node
const parentNode = element.parentNode;           // parent node (could be document)

// Children
const children = element.children;              // live HTMLCollection
const childNodes = element.childNodes;          // includes text nodes

// Siblings
const prev = element.previousElementSibling;    // previous element
const next = element.nextElementSibling;        // next element

// Closest ancestor matching selector
const form = element.closest('form');

closest() is particularly useful for event delegation — finding the nearest ancestor matching a selector from the event target.

Modifying Content

const el = document.querySelector('.content');

// Text content (safe, no HTML parsing)
el.textContent = 'Hello, World!';               // sets text
const text = el.textContent;                     // gets text (all descendants)

// HTML content (parses HTML — XSS risk!)
el.innerHTML = '<strong>Bold text</strong>';     // parses HTML
const html = el.innerHTML;                       // gets HTML

// Insert adjacent HTML (faster than innerHTML +=)
el.insertAdjacentHTML('beforebegin', '<div>...</div>');
el.insertAdjacentHTML('afterbegin', '<div>...</div>');
el.insertAdjacentHTML('beforeend', '<div>...</div>');
el.insertAdjacentHTML('afterend', '<div>...</div>');

Always use textContent when setting plain text. innerHTML with user input creates XSS vulnerabilities. insertAdjacentHTML is more efficient than innerHTML += because it does not serialize and re-parse the entire element content.

Creating and Inserting Elements

// Create
const div = document.createElement('div');
div.textContent = 'New element';
div.className = 'box';
div.dataset.id = '123';

// Insert at end
parent.appendChild(div);

// Insert at specific position
parent.insertBefore(div, referenceChild);
parent.append(div);             // modern, accepts multiple nodes/strings
parent.prepend(div);            // insert at beginning

// Insert relative to existing element
reference.after(div);           // after reference (same parent)
reference.before(div);          // before reference
reference.replaceWith(div);     // replaces reference

// Template cloning (fastest for repeated structures)
const template = document.querySelector('#item-template');
const clone = template.content.cloneNode(true);
clone.querySelector('.title').textContent = 'Item 1';
list.appendChild(clone);

document.createDocumentFragment() is useful for batch inserts — append many elements to a fragment, then insert the fragment once to minimize reflow.

Removing Elements

const el = document.querySelector('.remove-me');

// Modern API
el.remove();

// Older API
el.parentElement.removeChild(el);

// Clear all children
while (element.firstChild) {
    element.removeChild(element.firstChild);
---
// Or: element.innerHTML = ''; // simpler but leaks memory in some cases
// Or: element.replaceChildren(); // modern, fast

element.replaceChildren() is the cleanest way to remove all children — it accepts no arguments to clear, or new children to replace existing ones.

Working with Attributes

const input = document.querySelector('input');

// Standard properties
input.id = 'username';
input.value = 'alice';
input.disabled = true;

// Generic attribute methods
input.setAttribute('aria-label', 'Username input');
const label = input.getAttribute('aria-label');
input.hasAttribute('required');      // true/false
input.removeAttribute('disabled');

// Data attributes
input.dataset.type = 'text';         // sets data-type
const type = input.dataset.type;     // reads data-type

// Classes
input.classList.add('active');
input.classList.remove('inactive');
input.classList.toggle('visible');
input.classList.replace('old', 'new');
input.classList.contains('active');  // true/false

// Inline styles
input.style.display = 'block';
input.style.backgroundColor = 'red'; // camelCase
input.style.cssText = 'color: blue; font-size: 14px;';

Prefer classList manipulation over inline styles. Use dataset for data attributes. Use style properties for dynamic values computed at runtime.

Event Handling

const button = document.querySelector('button');

// Add listener
button.addEventListener('click', function(event) {
    console.log('Clicked!', event.target);
---);

// Arrow function (no own this)
button.addEventListener('click', (event) => {
    console.log('Clicked!', event.currentTarget);
---);

// Options
button.addEventListener('click', handler, {
    once: true,     // auto-remove after first invocation
    passive: true,  // cannot call preventDefault (performance)
    capture: true,  // listen in capture phase
---);

// Remove listener (requires named function)
button.removeEventListener('click', handler);

// Event object properties
event.target;            // element that triggered the event
event.currentTarget;     // element the listener is attached to
event.preventDefault();  // prevent default behavior
event.stopPropagation(); // stop bubbling
event.stopImmediatePropagation(); // stop all listeners on element

Event listeners use reference identity for removal — pass a named function, not an anonymous one. The passive: true option tells the browser that the handler will not call preventDefault, enabling scroll optimizations.

Event Delegation

Attach one listener to a parent instead of many to children:

document.querySelector('.list').addEventListener('click', (event) => {
    const item = event.target.closest('.list-item');
    if (item) {
        console.log('Clicked item:', item.dataset.id);
    }
---);

Event delegation works because most events bubble up from child to parent. Use closest() to find the relevant ancestor from the event target. This pattern is essential for dynamic lists where elements are added and removed frequently.

Custom Events

// Create and dispatch
const event = new CustomEvent('user-login', {
    detail: { userId: 123, role: 'admin' },
    bubbles: true,
---);
element.dispatchEvent(event);

// Listen
element.addEventListener('user-login', (event) => {
    console.log(event.detail); // { userId: 123, role: 'admin' }
---);

Custom events decouple components in complex applications. They follow the same dispatch and bubbling patterns as built-in events.

Performance Optimization

// Batch DOM reads/writes to avoid layout thrashing
// Bad: interleaved reads and writes
element.style.width = '100px';
const height = element.offsetHeight; // forces reflow
element.style.height = height + 'px';

// Good: batch reads, then batch writes
const height = element.offsetHeight; // read
element.style.width = '100px';       // write
element.style.height = height + 'px'; // write

// Use DocumentFragment for batch inserts
const fragment = document.createDocumentFragment();
for (const item of items) {
    const li = document.createElement('li');
    li.textContent = item;
    fragment.appendChild(li);
---
list.appendChild(fragment); // single reflow

// requestAnimationFrame for animations
function animate() {
    element.style.transform = `translateX(${pos}px)`;
    pos += 1;
    if (pos < 100) requestAnimationFrame(animate);
---
requestAnimationFrame(animate);

Layout thrashing occurs when you alternate DOM reads (that trigger reflow) and writes. Batch reads and writes to minimize forced synchronous layouts.

MutationObserver

Watch for DOM changes:

const observer = new MutationObserver((mutations) => {
    for (const mutation of mutations) {
        if (mutation.type === 'childList') {
            console.log('Children changed:', mutation.addedNodes);
        }
        if (mutation.type === 'attributes') {
            console.log(`Attribute ${mutation.attributeName} changed`);
        }
    }
---);

observer.observe(targetElement, {
    childList: true,    // child node additions/removals
    attributes: true,   // attribute changes
    subtree: true,      // observe descendants
    characterData: true // text content changes
---);

// Stop observing
observer.disconnect();

Shadow DOM for Encapsulation

Shadow DOM provides encapsulation for DOM subtrees:

const host = document.querySelector('#widget');
const shadow = host.attachShadow({ mode: 'open' });

// Styles inside shadow DOM don't leak out
shadow.innerHTML = `
    <style>
        .container { padding: 20px; background: #f0f0f0; }
        .title { font-size: 18px; font-weight: bold; }
    </style>
    <div class="container">
        <div class="title">Shadow DOM Widget</div>
        <slot></slot>
    </div>
`;

ResizeObserver

Watch element size changes for responsive layouts:

const observer = new ResizeObserver((entries) => {
    for (const entry of entries) {
        const { width, height } = entry.contentRect;
        console.log(`Element is now ${width}x${height}`);
        if (width < 600) {
            entry.target.classList.add('compact');
        } else {
            entry.target.classList.remove('compact');
        }
    }
---);

observer.observe(document.querySelector('#sidebar'));

IntersectionObserver for Lazy Loading

const imageObserver = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            const img = entry.target;
            img.src = img.dataset.src;
            img.onload = () => img.classList.add('loaded');
            imageObserver.unobserve(img);
        }
    });
---, {
    rootMargin: '200px', // start loading 200px before visible
---);

document.querySelectorAll('img[data-src]').forEach(img => {
    imageObserver.observe(img);
---);

Real-World Implementation Tips

Production Considerations

When moving from development to production, several factors become critical. Error handling should be comprehensive — every external call (database, API, file system) should have proper error checking, logging, and retry logic where appropriate. Performance monitoring through metrics and structured logging helps identify bottlenecks before they affect users.

Testing Strategy

A thorough testing approach combines multiple levels:

  • Unit tests verify individual functions and methods in isolation
  • Integration tests validate that components work together correctly
  • Edge case tests cover boundary conditions, empty inputs, and error states
  • Performance tests ensure the system meets latency and throughput requirements

Test data should be realistic but controlled. Mock external dependencies to make tests fast and deterministic. Aim for tests that are independent, repeatable, and fast enough to run on every commit.

Documentation

Good documentation is essential for maintainable code. Follow these principles:

  • Document the “why” not just the “what” — explain design decisions
  • Keep examples up to date with the code
  • Include usage examples for public APIs
  • Document configuration options and their defaults
  • Explain error conditions and recovery strategies

Security Best Practices

Security should be considered throughout development:

  • Validate all inputs at system boundaries
  • Use parameterized queries for database access
  • Store secrets in environment variables or secret managers
  • Keep dependencies updated to patch vulnerabilities
  • Apply the principle of least privilege

Performance Optimization

Optimize based on measured data, not assumptions:

  1. Profile before optimizing — identify actual bottlenecks
  2. Measure the impact of each change
  3. Consider the trade-off between speed and readability
  4. Cache expensive operations with appropriate invalidation
  5. Use connection pooling for database and network resources

Monitoring and Observability

Production systems need visibility:

  • Structured logging with correlation IDs for request tracking
  • Metrics for latency, throughput, error rates, and resource usage
  • Health check endpoints for load balancers and orchestration
  • Distributed tracing for request flows across services
  • Alerts for anomaly detection based on baselines

These patterns apply across all programming languages and frameworks. The specific implementation varies, but the principles remain consistent.

FAQ

Q: What is the difference between textContent and innerText? A: textContent returns all text content including hidden elements and script/style content. innerText respects styling (returns only visible text) and triggers reflow.

Q: How do I detect if an element is visible? A: Use element.getBoundingClientRect() for position/size visibility, IntersectionObserver for scroll visibility, or check element.offsetParent !== null for CSS visibility.

Q: What is the most efficient way to update many elements? A: Use DocumentFragment for inserts, batch DOM writes, avoid layout thrashing, and consider innerHTML for total content replacement (but only with trusted content).

Q: Should I use jQuery for DOM manipulation? A: Modern browsers implement the DOM APIs that made jQuery popular (querySelectorAll, classList, closest). jQuery is unnecessary for new projects but still used in legacy codebases.

Q: What is the difference between NodeList and HTMLCollection? A: NodeList (from querySelectorAll) is typically static. HTMLCollection (from getElementsBy*) is live — it updates when the DOM changes. Both are array-like but not arrays.

For a comprehensive overview, read our article on Javascript Arrays Guide.

For a comprehensive overview, read our article on Javascript Async Await.

Section: JavaScript 1698 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top