Skip to main content

DOM Manipulation

DOM Manipulation — createElement, event delegation, classList

The DOM (Document Object Model) is the browser's representation of your HTML as a tree of objects. Manipulating it directly is the foundation of every frontend framework.

1. createElement — Creating elements dynamically

Instead of writing HTML, you can create elements with JavaScript.

Basic usage

// Create an element
const div = document.createElement('div');
div.textContent = 'Hello World';
div.id = 'myDiv';
div.className = 'box active';

// Add to DOM
document.body.appendChild(div);

Creating a list dynamically

const ul = document.createElement('ul');
const items = ['Apple', 'Banana', 'Mango'];

items.forEach(item => {
const li = document.createElement('li');
li.textContent = item;
ul.appendChild(li);
});

document.body.appendChild(ul);

Key DOM insertion methods

MethodWhat it does
parent.appendChild(child)Add child at the end
parent.prepend(child)Add child at the beginning
parent.insertBefore(newNode, refNode)Add before a reference node
parent.removeChild(child)Remove a child element
element.remove()Element removes itself
parent.replaceChild(newChild, oldChild)Replace a child

innerHTML vs createElement

// innerHTML — quick but risky (XSS vulnerability)
container.innerHTML = `<div class="card">${userInput}</div>`;
// If userInput = '<script>alert("hacked")</script>' — XSS attack!

// createElement — safe, no XSS risk
const div = document.createElement('div');
div.className = 'card';
div.textContent = userInput; // textContent auto-escapes HTML
container.appendChild(div);
  • createElement — when content comes from user input (safe from XSS)
  • innerHTML — only when you control the HTML and need quick static markup

2. classList — Managing CSS classes

All classList methods

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

box.classList.add('active'); // Add one class
box.classList.add('bold', 'red', 'lg'); // Add multiple classes
box.classList.remove('active'); // Remove class
box.classList.remove('bold', 'red'); // Remove multiple
box.classList.toggle('active'); // Add if absent, remove if present
box.classList.contains('active'); // Returns true/false
box.classList.replace('old', 'new'); // Swap one class for another

Why classList over className?

// ❌ className OVERWRITES all existing classes
box.className = 'active';
// If box had "card shadow" → now it only has "active"

// ✅ classList adds/removes WITHOUT affecting other classes
box.classList.add('active');
// If box had "card shadow" → now it has "card shadow active"

Practical example — Toggle dark mode

const toggleBtn = document.getElementById('theme-toggle');

toggleBtn.addEventListener('click', () => {
document.body.classList.toggle('dark-mode');

// Update button text based on current state
const isDark = document.body.classList.contains('dark-mode');
toggleBtn.textContent = isDark ? '☀️ Light' : '🌙 Dark';
});

3. Event Delegation — The most important DOM concept

What is it?

Instead of attaching event listeners to each child element, you attach one listener to the parent. The parent catches events from all its children using event bubbling.

closest() — The delegation helper

Before diving in, know this method — it's the backbone of delegation:

// closest() starts from the element and walks UP the DOM tree
// until it finds an ancestor matching the selector

element.closest('.item');
User clicks <span class="name"> inside a nested structure:

<ul>
<li class="item">
<div class="info">
<span class="name">John</span> ← clicked here (e.target)
</div>
</li>
</ul>

closest('.item') searches upward:
<span class="name"> → has .item? NO
<div class="info"> → has .item? NO
<li class="item"> → has .item? YES ← returns this!

You'll see this used everywhere below. Now let's understand the problem delegation solves:

The problem — listeners on every element

// ❌ BAD: One listener per button
const buttons = document.querySelectorAll('.btn');
buttons.forEach(btn => {
btn.addEventListener('click', () => {
console.log('clicked', btn.textContent);
});
});

Problems with this approach:

  1. 100 buttons = 100 event listeners = memory waste
  2. Dynamically added buttons won't have listeners
  3. You need to manually clean up listeners to avoid memory leaks

The solution — one listener on the parent

// ✅ GOOD: One listener on parent catches ALL child clicks
document.querySelector('.button-container').addEventListener('click', (e) => {
if (e.target.classList.contains('btn')) {
console.log('clicked', e.target.textContent);
}
});

Benefits:

  1. 100 buttons = 1 event listener
  2. Dynamically added buttons automatically work
  3. No cleanup needed

Why does this work? — Event Propagation (3 Phases)

When you click an element, the event travels through 3 phases:

Click on <li>

PHASE 1 — CAPTURING (top → down)
Browser goes from root DOWN to the clicked element

document → html → body → ul → li

PHASE 2 — TARGET
Event fires on the actual clicked element


PHASE 3 — BUBBLING (bottom → up)
Event travels back UP from clicked element to root

document ← html ← body ← ul ← li

By default, listeners fire during the bubbling phase (Phase 3). That's why a listener on <ul> catches clicks from <li> — the click bubbles up from <li> to <ul>.

Capturing vs Bubbling — addEventListener third argument

// Default: fires during BUBBLING (Phase 3)
parent.addEventListener('click', handler);
parent.addEventListener('click', handler, false); // same thing

// Fires during CAPTURING (Phase 1)
parent.addEventListener('click', handler, true);
// Example: See the order
document.body.addEventListener('click', () => console.log('body CAPTURE'), true);
document.body.addEventListener('click', () => console.log('body BUBBLE'));

ul.addEventListener('click', () => console.log('ul CAPTURE'), true);
ul.addEventListener('click', () => console.log('ul BUBBLE'));

li.addEventListener('click', () => console.log('li TARGET'));

// Click on <li> — Output:
// "body CAPTURE" ← Phase 1 (top → down)
// "ul CAPTURE" ← Phase 1
// "li TARGET" ← Phase 2
// "ul BUBBLE" ← Phase 3 (bottom → up)
// "body BUBBLE" ← Phase 3

Almost never. 99% of the time bubbling is what you want. Capturing is used in rare cases like intercepting events before they reach children (e.g., analytics, global click tracking).

e.target vs e.currentTarget

This is the most asked follow-up question in interviews:

<ul id="list">
<li>
<span>Item 1</span>
</li>
</ul>
document.getElementById('list').addEventListener('click', (e) => {
console.log('target:', e.target); // <span> — what was actually clicked
console.log('currentTarget:', e.currentTarget); // <ul> — where the listener is
});
PropertyPoints toChanges?
e.targetThe deepest element that was clickedDifferent for each click
e.currentTargetThe element the listener is attached toAlways the same

Visual example:

User clicks on the word "Item 1" which is inside <span>

<ul> ← e.currentTarget (listener is here)
<li>
<span>Item 1</span> ← e.target (this was clicked)
</li>
</ul>

closest() — The delegation helper

The biggest pitfall with delegation: e.target might be a deeply nested child, not the element you care about.

<ul id="list">
<li class="item" data-id="1">
<img src="avatar.png" />
<div class="info">
<span class="name">John</span>
<span class="email">john@mail.com</span>
</div>
<button class="delete">X</button>
</li>
</ul>

If user clicks on <span class="name">, e.target is the <span>, not the <li>. So how do you find the <li>?

list.addEventListener('click', (e) => {
// closest() walks UP the DOM tree from e.target
// until it finds an element matching the selector
const li = e.target.closest('.item');
if (!li) return; // clicked somewhere outside an .item

const id = li.dataset.id; // "1"

if (e.target.closest('.delete')) {
console.log('Delete item', id);
li.remove();
}
});

How closest() works:

User clicks <span class="name">

closest('.item') searches:
<span class="name"> → has .item? NO
<div class="info"> → has .item? NO
<li class="item"> → has .item? YES ← returns this!

Stopping propagation

// Stop event from bubbling up — parent listeners WON'T fire
child.addEventListener('click', (e) => {
e.stopPropagation();
});

// Stop event AND prevent other listeners on SAME element
child.addEventListener('click', (e) => {
e.stopImmediatePropagation();
});

// Prevent default browser behavior (link navigation, form submit)
link.addEventListener('click', (e) => {
e.preventDefault();
});
MethodWhat it does
e.stopPropagation()Stops bubbling — parent listeners won't fire
e.stopImmediatePropagation()Stops bubbling + stops other listeners on same element
e.preventDefault()Prevents default action (navigate, submit, etc.)

Avoid stopPropagation() in delegation patterns — it breaks the whole point. If a child stops propagation, the parent delegate listener never receives the event.

Real-world patterns using Event Delegation

Pattern 1: Action buttons in a table

<table id="users-table">
<tr data-id="101">
<td>John</td>
<td>
<button class="edit">Edit</button>
<button class="delete">Delete</button>
</td>
</tr>
<tr data-id="102">
<td>Jane</td>
<td>
<button class="edit">Edit</button>
<button class="delete">Delete</button>
</td>
</tr>
</table>
// ONE listener handles edit + delete for ALL rows (even future ones)
document.getElementById('users-table').addEventListener('click', (e) => {
const row = e.target.closest('tr');
if (!row) return;
const userId = row.dataset.id;

if (e.target.classList.contains('edit')) {
console.log('Edit user', userId);
}
if (e.target.classList.contains('delete')) {
row.remove();
console.log('Deleted user', userId);
}
});

Pattern 2: Tab switching

<div class="tabs">
<button class="tab" data-tab="home">Home</button>
<button class="tab" data-tab="profile">Profile</button>
<button class="tab" data-tab="settings">Settings</button>
</div>
<div class="tab-content" id="tab-content"></div>
document.querySelector('.tabs').addEventListener('click', (e) => {
const tab = e.target.closest('.tab');
if (!tab) return;

// Remove active from all tabs
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));

// Add active to clicked tab
tab.classList.add('active');

// Update content
const tabName = tab.dataset.tab;
document.getElementById('tab-content').textContent = `Content for ${tabName}`;
});

Pattern 3: Dynamic list with add/remove

const list = document.getElementById('list');
const input = document.getElementById('input');

// Add items dynamically — they automatically get delegation support
document.getElementById('add-btn').addEventListener('click', () => {
const li = document.createElement('li');
li.innerHTML = `
<span>${input.value}</span>
<button class="remove">X</button>
`;
list.appendChild(li);
input.value = '';
});

// ONE listener handles remove for all items — past and future
list.addEventListener('click', (e) => {
if (e.target.classList.contains('remove')) {
e.target.closest('li').remove();
}
});

Which events DON'T bubble?

Not all events bubble. These are the exceptions:

EventBubbles?Alternative
focusNoUse focusin (bubbles)
blurNoUse focusout (bubbles)
mouseenterNoUse mouseover (bubbles)
mouseleaveNoUse mouseout (bubbles)
loadNo
scrollNo
// ❌ Won't work with delegation — focus doesn't bubble
parent.addEventListener('focus', handler);

// ✅ Works — focusin bubbles
parent.addEventListener('focusin', handler);

How React uses Event Delegation internally

React doesn't attach listeners to individual DOM elements. It uses delegation under the hood:

React 16: All listeners attached to document
React 17+: All listeners attached to the root container (#root)

When you write:

<button onClick={handleClick}>Click</button>

React doesn't do button.addEventListener('click', handleClick). Instead, it attaches ONE listener to #root and uses its Synthetic Event system to figure out which component's handler to call. This is exactly event delegation.

That's why e.nativeEvent gives you the real DOM event in React, and e is React's synthetic wrapper

closest() — Finding the right parent

When elements have nested children, e.target might be a child element, not the one you want:

<ul id="list">
<li class="item">
<span class="text">Buy groceries</span>
<button class="delete">X</button>
</li>
</ul>
// User clicks on <span> inside <li>
// e.target = <span>, but we want the <li>

list.addEventListener('click', (e) => {
const li = e.target.closest('.item'); // walks UP the DOM to find .item
if (!li) return;

if (e.target.classList.contains('delete')) {
li.remove();
}
});

closest('.item') starts from e.target and walks up the DOM tree until it finds an element matching the selector.

Stopping event propagation

// Stop the event from bubbling up
child.addEventListener('click', (e) => {
e.stopPropagation(); // Parent listeners won't fire
});

// Prevent default browser behavior
link.addEventListener('click', (e) => {
e.preventDefault(); // Link won't navigate
});

4. Complete Example — Dynamic Todo List

Combining all three concepts — createElement, classList, and event delegation:

<div id="app">
<input id="todo-input" placeholder="Add a todo..." />
<button id="add-btn">Add</button>
<ul id="todo-list"></ul>
</div>
const input = document.getElementById('todo-input');
const addBtn = document.getElementById('add-btn');
const list = document.getElementById('todo-list');

// --- createElement: Build todo items dynamically ---
function createTodoItem(text) {
const li = document.createElement('li');
li.className = 'todo-item';

const span = document.createElement('span');
span.className = 'todo-text';
span.textContent = text;

const doneBtn = document.createElement('button');
doneBtn.className = 'done-btn';
doneBtn.textContent = '✓';

const deleteBtn = document.createElement('button');
deleteBtn.className = 'delete-btn';
deleteBtn.textContent = '✕';

li.appendChild(span);
li.appendChild(doneBtn);
li.appendChild(deleteBtn);

return li;
}

// Add new todo
addBtn.addEventListener('click', () => {
const text = input.value.trim();
if (!text) return;

const todoItem = createTodoItem(text);
list.appendChild(todoItem);
input.value = '';
input.focus();
});

// Enter key support
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') addBtn.click();
});

// --- Event Delegation: ONE listener handles all todo actions ---
list.addEventListener('click', (e) => {
const li = e.target.closest('.todo-item');
if (!li) return;

// classList: Toggle done state
if (e.target.classList.contains('done-btn')) {
li.querySelector('.todo-text').classList.toggle('done');
}

// Remove todo
if (e.target.classList.contains('delete-btn')) {
li.remove();
}
});
.todo-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-bottom: 1px solid #eee;
}
.todo-text.done {
text-decoration: line-through;
color: #999;
}
.delete-btn { color: red; }
.done-btn { color: green; }

What's happening:

  1. createElement — builds each todo item safely (no innerHTML)
  2. classList.toggle('done') — toggles strikethrough without affecting other classes
  3. Event delegation — one listener on <ul> handles done + delete for ALL todos (even future ones)

Quick Reference Cheat Sheet

MethodWhat it does
document.createElement('div')Create new element
parent.appendChild(child)Add child at end
parent.prepend(child)Add child at start
element.remove()Remove element from DOM
classList.add('x')Add CSS class
classList.remove('x')Remove CSS class
classList.toggle('x')Toggle CSS class
classList.contains('x')Check if class exists
classList.replace('a', 'b')Swap class a with b
addEventListener('click', fn)Attach event listener
e.targetElement that triggered the event
e.currentTargetElement with the listener
e.target.closest('.x')Find nearest ancestor matching selector
e.stopPropagation()Stop event from bubbling
e.preventDefault()Prevent default browser action

Interview Questions

Q: What is event delegation and why use it?

Attaching a single event listener to a parent element instead of individual listeners on each child. Events bubble up from child to parent, so the parent catches them. Benefits: fewer listeners (better memory), works for dynamically added elements.

Q: Difference between e.target and e.currentTarget?

e.target is the actual element clicked (deepest child). e.currentTarget is the element the listener is attached to. In delegation, e.currentTarget is always the parent.

Q: Why prefer classList.add() over className = 'x'?

className overwrites ALL existing classes. classList.add() only adds the new class without removing existing ones.

Q: What's the risk with innerHTML?

XSS attacks. If user input is inserted via innerHTML, malicious scripts can execute. createElement + textContent is safe because textContent auto-escapes HTML entities.

Q: What are the three phases of event propagation?

  1. Capturing — event travels from document down to target
  2. Target — event fires on the clicked element
  3. Bubbling — event travels back up from target to document