DOM Manipulation: Modern API Deep Dive
A comprehensive guide to modern DOM manipulation APIs covering selectors, element creation, classList, dataset, MutationObserver, and performance optimization.
DOM Manipulation: Modern API Deep Dive
Overview
The DOM API has evolved significantly since the days of document.getElementById and jQuery workarounds. Modern browsers ship with powerful, expressive APIs for selecting, creating, modifying, observing, and traversing elements — and most developers are only scratching the surface. This article is a deep technical walkthrough of every DOM manipulation API that matters in production, from querySelector to MutationObserver to ResizeObserver, with working code you can drop into real projects.
Prerequisites
- Solid understanding of HTML and CSS
- Intermediate JavaScript (functions, objects, event handling)
- A modern browser (Chrome 90+, Firefox 90+, Safari 15+, Edge 90+)
- Basic familiarity with browser DevTools
Selectors: querySelector and querySelectorAll vs getElementById
getElementById is fast but limited. querySelector and querySelectorAll accept any valid CSS selector, making them dramatically more flexible.
// Old approach — one element by ID
var header = document.getElementById('main-header');
// Modern approach — CSS selectors
var header = document.querySelector('#main-header');
var allCards = document.querySelectorAll('.card[data-active="true"]');
var firstVisible = document.querySelector('.item:not(.hidden)');
Key differences worth knowing:
getElementByIdreturns a live reference.querySelectorreturns a static snapshot.querySelectorAllreturns a staticNodeList, not a liveHTMLCollection. This means elements added after the query will not appear in the list.querySelectorAllsupportsforEachnatively.getElementsByClassNamereturns a liveHTMLCollectionthat does not.
// querySelectorAll supports forEach directly
document.querySelectorAll('.tab').forEach(function(tab) {
tab.addEventListener('click', handleTabClick);
});
// getElementsByClassName returns a live HTMLCollection — no forEach
var items = document.getElementsByClassName('item');
// You'd need: Array.prototype.forEach.call(items, fn)
One thing I'll say plainly: unless you specifically need a live collection (rare), use querySelector and querySelectorAll for everything. The performance difference is negligible in all but the most extreme cases, and the selector flexibility is worth it.
Element Creation and Insertion
createElement builds elements in memory. The modern insertion methods — append, prepend, before, after, replaceWith — replace the awkward appendChild and insertBefore patterns.
var card = document.createElement('div');
card.className = 'card';
card.textContent = 'New card content';
var container = document.querySelector('.container');
// Modern insertion methods
container.append(card); // Add as last child
container.prepend(card); // Add as first child
var ref = document.querySelector('.reference');
ref.before(card); // Insert before the reference element
ref.after(card); // Insert after the reference element
ref.replaceWith(card); // Replace the reference element entirely
These methods also accept strings directly, which get inserted as text nodes:
container.append('Some text', anotherElement, 'More text');
Compare this with the old insertBefore pattern, which required you to know the parent and the reference child:
// Old way — clunky
parent.insertBefore(newNode, referenceNode);
// New way — reads naturally
referenceNode.before(newNode);
Removing Elements
The remove() method is clean and direct. No more reaching up to the parent.
// Modern — element removes itself
document.querySelector('.modal').remove();
// Old way — parent removes child
var child = document.getElementById('old-item');
child.parentNode.removeChild(child);
remove() is supported in all modern browsers. If you're maintaining legacy code, you'll still see removeChild, but there is no reason to write new code that way.
Cloning Nodes
cloneNode creates a copy of an element. The deep parameter controls whether children come along.
var template = document.querySelector('.card-template');
// Shallow clone — element only, no children
var shallow = template.cloneNode(false);
// Deep clone — element and all descendants
var deep = template.cloneNode(true);
// Important: cloned elements retain the same IDs
// Always update IDs after cloning to avoid duplicates
deep.id = 'card-' + Date.now();
Deep cloning copies event listeners added via addEventListener? No — it does not. Only inline event handlers (onclick="...") survive cloning. Listeners added programmatically must be re-attached manually.
DocumentFragment for Batch Operations
When you need to insert many elements at once, DocumentFragment is the right tool. It acts as a lightweight container that, when appended, transfers its children into the DOM in a single operation — triggering only one reflow instead of many.
var fragment = document.createDocumentFragment();
var data = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'];
data.forEach(function(name) {
var li = document.createElement('li');
li.textContent = name;
li.className = 'user-item';
fragment.appendChild(li);
});
// Single DOM insertion — one reflow
document.querySelector('.user-list').appendChild(fragment);
This pattern matters in performance-critical scenarios. Inserting 1000 elements one at a time can cause noticeable jank. Batching through a fragment eliminates that.
Dataset API for Data Attributes
The dataset API provides clean read/write access to data-* attributes. Attribute names are automatically converted from kebab-case to camelCase.
// <div id="user" data-user-id="42" data-role="admin" data-last-login="2026-01-15">
var el = document.querySelector('#user');
// Reading
var userId = el.dataset.userId; // "42"
var role = el.dataset.role; // "admin"
var lastLogin = el.dataset.lastLogin; // "2026-01-15"
// Writing
el.dataset.status = 'active';
// Result: <div ... data-status="active">
// Deleting
delete el.dataset.status;
// Checking existence
if ('userId' in el.dataset) {
console.log('User ID is set');
}
Values are always strings. Parse them yourself when you need numbers or booleans. The dataset API is far more readable than getAttribute('data-user-id') and I use it exclusively.
classList API
classList replaces manual className string manipulation. It provides methods for every common class operation.
var el = document.querySelector('.panel');
el.classList.add('visible', 'animated'); // Add one or more classes
el.classList.remove('hidden', 'collapsed'); // Remove one or more
el.classList.toggle('expanded'); // Toggle on/off
el.classList.toggle('dark', isDarkMode); // Force add/remove based on condition
el.classList.contains('visible'); // Returns true/false
el.classList.replace('old-class', 'new-class'); // Swap one class for another
The toggle method with a second boolean argument is particularly useful. It lets you set a class conditionally without if/else blocks:
el.classList.toggle('error', !isValid);
el.classList.toggle('success', isValid);
Style Manipulation: Inline Styles vs CSS Classes
Direct style manipulation through element.style sets inline styles. Use it sparingly — CSS classes are almost always the better choice.
// Inline styles (use for dynamic values only)
el.style.transform = 'translateX(' + offset + 'px)';
el.style.setProperty('--progress', percentage + '%');
// Reading computed styles (what's actually rendered)
var computed = window.getComputedStyle(el);
var actualWidth = computed.width; // "320px"
var actualColor = computed.color; // "rgb(0, 0, 0)"
// Prefer classes for state changes
el.classList.add('is-loading'); // Better than el.style.opacity = '0.5'
The setProperty method is valuable for setting CSS custom properties (variables) from JavaScript. This is a clean bridge between JS logic and CSS presentation.
Attribute Manipulation
For non-data, non-class, non-style attributes, use the attribute methods directly.
var link = document.querySelector('a.external');
link.getAttribute('href'); // Read
link.setAttribute('target', '_blank'); // Write
link.hasAttribute('rel'); // Check existence
link.removeAttribute('title'); // Remove
// Boolean attributes
var input = document.querySelector('input');
input.toggleAttribute('disabled'); // Toggle boolean attribute
input.toggleAttribute('required', true); // Force set
toggleAttribute is the boolean attribute equivalent of classList.toggle — clean and expressive.
innerHTML vs textContent vs innerText
These three properties serve different purposes and have different performance and security profiles.
var el = document.querySelector('.content');
// textContent — fast, returns raw text, no HTML parsing
el.textContent = 'Hello <b>world</b>';
// Renders as literal text: Hello <b>world</b>
// innerHTML — parses HTML, potential XSS vector
el.innerHTML = '<b>Hello</b> world';
// Renders as: Hello (bold) world
// innerText — layout-aware, slower, respects CSS visibility
var visible = el.innerText; // Only returns visible text
Security rule: never set innerHTML with user-supplied input unless it has been sanitized. Use textContent for user data. Always.
// DANGEROUS — XSS vulnerability
el.innerHTML = userInput;
// SAFE — treated as text, not HTML
el.textContent = userInput;
insertAdjacentHTML and insertAdjacentElement
These methods give you precise control over where new content is inserted, with four position options.
var el = document.querySelector('.target');
// Four positions:
// 'beforebegin' — before the element itself
// 'afterbegin' — inside, before first child
// 'beforeend' — inside, after last child
// 'afterend' — after the element itself
el.insertAdjacentHTML('beforeend', '<span class="badge">New</span>');
el.insertAdjacentElement('afterend', document.createElement('hr'));
el.insertAdjacentText('afterbegin', 'Note: ');
insertAdjacentHTML is faster than setting innerHTML because it does not re-parse existing content. When you need to append HTML strings without destroying existing DOM state (including event listeners), this is the method to use.
MutationObserver for DOM Change Detection
MutationObserver watches for changes to the DOM and fires a callback. It replaces the deprecated Mutation Events (DOMNodeInserted, etc.) which had severe performance problems.
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'childList') {
console.log('Children changed:', mutation.addedNodes, mutation.removedNodes);
}
if (mutation.type === 'attributes') {
console.log('Attribute changed:', mutation.attributeName);
}
});
});
var target = document.querySelector('#dynamic-content');
observer.observe(target, {
childList: true, // Watch for child additions/removals
attributes: true, // Watch for attribute changes
subtree: true, // Watch entire subtree, not just direct children
characterData: true, // Watch for text content changes
attributeFilter: ['class', 'data-state'] // Only specific attributes
});
// Stop observing when done
observer.disconnect();
Real-world use case: watching for third-party scripts that inject elements, or detecting when a lazy-loaded component renders.
Template Element for Reusable HTML
The <template> element holds markup that is not rendered until you clone and insert it. The browser parses the HTML but does not execute scripts or load images inside templates.
// In HTML:
// <template id="row-template">
// <tr>
// <td class="name"></td>
// <td class="email"></td>
// <td><button class="btn-edit">Edit</button></td>
// </tr>
// </template>
var template = document.querySelector('#row-template');
function createRow(user) {
var clone = template.content.cloneNode(true);
clone.querySelector('.name').textContent = user.name;
clone.querySelector('.email').textContent = user.email;
return clone;
}
var tbody = document.querySelector('tbody');
users.forEach(function(user) {
tbody.appendChild(createRow(user));
});
Templates are cleaner than building complex HTML structures with createElement chains. Use them when you have repeating patterns with many elements.
Shadow DOM Basics
Shadow DOM encapsulates a component's internal structure and styles, preventing leakage in either direction. It is the foundation of Web Components.
var host = document.querySelector('#widget');
var shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<style>.title { color: blue; font-weight: bold; }</style>' +
'<div class="title">Widget Title</div>' +
'<slot></slot>';
// Styles inside shadow DOM don't affect the main document
// Main document styles don't affect shadow DOM content
// 'open' mode: shadow root accessible via host.shadowRoot
// 'closed' mode: shadow root is null from outside
Shadow DOM is powerful but adds complexity. Use it for widget-style components that must be style-isolated. For most application code, it is overkill.
Traversal Methods
Modern traversal methods let you navigate the DOM tree without manual loops.
var el = document.querySelector('.item');
// closest — walks UP the tree to find matching ancestor
var card = el.closest('.card'); // Nearest ancestor matching selector
var form = el.closest('form'); // Nearest ancestor form
// matches — tests if element matches a selector
if (el.matches('.active, .highlighted')) {
// Element matches at least one of those selectors
}
// Tree navigation
var parent = el.parentElement;
var kids = el.children; // HTMLCollection of element children
var first = el.firstElementChild;
var last = el.lastElementChild;
var next = el.nextElementSibling;
var prev = el.previousElementSibling;
closest is exceptionally useful in event delegation:
document.querySelector('.table').addEventListener('click', function(e) {
var row = e.target.closest('tr');
if (!row) return;
var id = row.dataset.id;
// Handle row click regardless of which child element was clicked
});
getBoundingClientRect for Position and Size
getBoundingClientRect returns an element's position and dimensions relative to the viewport.
var rect = el.getBoundingClientRect();
console.log(rect.top); // Distance from viewport top
console.log(rect.left); // Distance from viewport left
console.log(rect.width); // Element width including padding and border
console.log(rect.height); // Element height
console.log(rect.bottom); // rect.top + rect.height
console.log(rect.right); // rect.left + rect.width
// Check if element is in viewport
function isInViewport(el) {
var rect = el.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
);
}
This is the foundation for tooltip positioning, intersection detection (before IntersectionObserver existed), and custom layout calculations.
scrollIntoView and Scroll Behavior
scrollIntoView scrolls the page to bring an element into view. The options object controls the behavior.
// Basic scroll
el.scrollIntoView();
// Smooth scroll with alignment options
el.scrollIntoView({
behavior: 'smooth', // 'smooth' or 'auto' (instant)
block: 'center', // Vertical: 'start', 'center', 'end', 'nearest'
inline: 'nearest' // Horizontal alignment
});
// Programmatic smooth scroll to coordinates
window.scrollTo({
top: 0,
behavior: 'smooth'
});
This replaces jQuery's $('html, body').animate({ scrollTop: ... }) entirely.
ResizeObserver for Element Resizing
ResizeObserver fires when an element's size changes — from CSS, content changes, or viewport resizing. Unlike the resize window event, it works on individual elements.
var observer = new ResizeObserver(function(entries) {
entries.forEach(function(entry) {
var width = entry.contentRect.width;
var height = entry.contentRect.height;
// Respond to size changes
if (width < 400) {
entry.target.classList.add('compact');
} else {
entry.target.classList.remove('compact');
}
});
});
observer.observe(document.querySelector('.responsive-panel'));
// Stop observing
// observer.unobserve(element);
// observer.disconnect();
ResizeObserver is the right tool for container-based responsive behavior — adapting a component's layout based on its own size rather than the viewport.
Performance Considerations
DOM manipulation is the most common source of front-end performance problems. Understanding reflow and repaint is essential.
Reflow (layout recalculation) is expensive. It happens when you change geometry — width, height, position, margins, padding, font-size. Repaint is cheaper — it happens when you change appearance without affecting layout (color, background, visibility).
// BAD — triggers reflow on every iteration
var list = document.querySelector('.list');
for (var i = 0; i < 1000; i++) {
var item = document.createElement('div');
item.textContent = 'Item ' + i;
list.appendChild(item); // Reflow on each append
}
// GOOD — batch with DocumentFragment
var fragment = document.createDocumentFragment();
for (var i = 0; i < 1000; i++) {
var item = document.createElement('div');
item.textContent = 'Item ' + i;
fragment.appendChild(item);
}
list.appendChild(fragment); // Single reflow
// BAD — reading and writing layout properties alternately (layout thrashing)
elements.forEach(function(el) {
var height = el.offsetHeight; // Read — triggers layout
el.style.height = (height + 10) + 'px'; // Write — invalidates layout
});
// GOOD — batch reads, then batch writes
var heights = elements.map(function(el) {
return el.offsetHeight; // All reads first
});
elements.forEach(function(el, i) {
el.style.height = (heights[i] + 10) + 'px'; // All writes second
});
Virtual scrolling is the technique of only rendering elements that are currently visible in the viewport. For lists with thousands of items, this can reduce DOM node count from thousands to dozens:
function virtualScroll(container, items, rowHeight) {
var totalHeight = items.length * rowHeight;
var spacer = document.createElement('div');
spacer.style.height = totalHeight + 'px';
container.appendChild(spacer);
container.addEventListener('scroll', function() {
var scrollTop = container.scrollTop;
var startIndex = Math.floor(scrollTop / rowHeight);
var endIndex = Math.min(startIndex + Math.ceil(container.clientHeight / rowHeight) + 1, items.length);
// Clear visible rows and re-render only what's needed
var visibleArea = container.querySelector('.visible-rows');
visibleArea.innerHTML = '';
visibleArea.style.transform = 'translateY(' + (startIndex * rowHeight) + 'px)';
for (var i = startIndex; i < endIndex; i++) {
var row = document.createElement('div');
row.style.height = rowHeight + 'px';
row.textContent = items[i];
visibleArea.appendChild(row);
}
});
}
Complete Working Example: Dynamic Data Table Component
Here is a full data table component that uses modern DOM APIs — createElement, classList, dataset, event delegation, DocumentFragment, insertAdjacentHTML, and closest — to support sorting, filtering, pagination, row selection, and inline editing.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Dynamic Data Table</title>
<style>
.data-table { width: 100%; border-collapse: collapse; font-family: sans-serif; }
.data-table th, .data-table td { padding: 10px 14px; border: 1px solid #ddd; text-align: left; }
.data-table th { background: #f5f5f5; cursor: pointer; user-select: none; position: relative; }
.data-table th.sort-asc::after { content: ' ▲'; }
.data-table th.sort-desc::after { content: ' ▼'; }
.data-table tr.selected { background: #e3f2fd; }
.data-table tr:hover { background: #fafafa; }
.data-table tr.selected:hover { background: #bbdefb; }
.data-table td[contenteditable="true"] { background: #fff9c4; outline: 2px solid #fbc02d; }
.table-controls { margin-bottom: 12px; display: flex; gap: 12px; align-items: center; }
.table-controls input { padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; }
.pagination { margin-top: 12px; display: flex; gap: 6px; }
.pagination button { padding: 6px 12px; border: 1px solid #ccc; background: #fff; cursor: pointer; border-radius: 4px; }
.pagination button.active { background: #1976d2; color: #fff; border-color: #1976d2; }
.pagination button:disabled { opacity: 0.5; cursor: default; }
.selection-info { font-size: 13px; color: #666; }
</style>
</head>
<body>
<div id="table-app"></div>
<script>
(function() {
// Sample dataset
var DATA = [
{ id: 1, name: 'Alice Johnson', email: '[email protected]', role: 'Engineer', salary: 95000 },
{ id: 2, name: 'Bob Smith', email: '[email protected]', role: 'Designer', salary: 82000 },
{ id: 3, name: 'Charlie Brown', email: '[email protected]', role: 'Manager', salary: 110000 },
{ id: 4, name: 'Diana Ross', email: '[email protected]', role: 'Engineer', salary: 98000 },
{ id: 5, name: 'Eve Martinez', email: '[email protected]', role: 'QA', salary: 78000 },
{ id: 6, name: 'Frank Lee', email: '[email protected]', role: 'Engineer', salary: 102000 },
{ id: 7, name: 'Grace Kim', email: '[email protected]', role: 'Designer', salary: 85000 },
{ id: 8, name: 'Hank Wilson', email: '[email protected]', role: 'Manager', salary: 115000 },
{ id: 9, name: 'Ivy Chen', email: '[email protected]', role: 'QA', salary: 76000 },
{ id: 10, name: 'Jack Davis', email: '[email protected]', role: 'Engineer', salary: 91000 },
{ id: 11, name: 'Karen White', email: '[email protected]', role: 'Designer', salary: 88000 },
{ id: 12, name: 'Leo Thomas', email: '[email protected]', role: 'Engineer', salary: 105000 }
];
var COLUMNS = ['name', 'email', 'role', 'salary'];
var PAGE_SIZE = 5;
var state = {
data: DATA.slice(),
filtered: DATA.slice(),
sortCol: null,
sortDir: 'asc',
currentPage: 1,
selected: {},
filter: ''
};
var root = document.querySelector('#table-app');
// Build controls
var controls = document.createElement('div');
controls.className = 'table-controls';
var filterInput = document.createElement('input');
filterInput.type = 'text';
filterInput.placeholder = 'Filter by name or role...';
filterInput.setAttribute('aria-label', 'Filter table');
controls.appendChild(filterInput);
var selectionInfo = document.createElement('span');
selectionInfo.className = 'selection-info';
controls.appendChild(selectionInfo);
root.appendChild(controls);
// Build table
var table = document.createElement('table');
table.className = 'data-table';
var thead = document.createElement('thead');
var headerRow = document.createElement('tr');
// Checkbox header
var thCheck = document.createElement('th');
var selectAll = document.createElement('input');
selectAll.type = 'checkbox';
selectAll.setAttribute('aria-label', 'Select all rows');
thCheck.appendChild(selectAll);
headerRow.appendChild(thCheck);
// Column headers
COLUMNS.forEach(function(col) {
var th = document.createElement('th');
th.textContent = col.charAt(0).toUpperCase() + col.slice(1);
th.dataset.column = col;
th.setAttribute('role', 'columnheader');
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
table.appendChild(thead);
var tbody = document.createElement('tbody');
table.appendChild(tbody);
root.appendChild(table);
// Build pagination
var pagination = document.createElement('div');
pagination.className = 'pagination';
root.appendChild(pagination);
// --- Rendering ---
function render() {
// Apply filter
var term = state.filter.toLowerCase();
state.filtered = state.data.filter(function(row) {
return row.name.toLowerCase().indexOf(term) !== -1 ||
row.role.toLowerCase().indexOf(term) !== -1;
});
// Apply sort
if (state.sortCol) {
var dir = state.sortDir === 'asc' ? 1 : -1;
var col = state.sortCol;
state.filtered.sort(function(a, b) {
if (a[col] < b[col]) return -1 * dir;
if (a[col] > b[col]) return 1 * dir;
return 0;
});
}
// Pagination
var totalPages = Math.ceil(state.filtered.length / PAGE_SIZE);
if (state.currentPage > totalPages) state.currentPage = totalPages || 1;
var start = (state.currentPage - 1) * PAGE_SIZE;
var pageData = state.filtered.slice(start, start + PAGE_SIZE);
// Render rows with DocumentFragment
var fragment = document.createDocumentFragment();
pageData.forEach(function(row) {
var tr = document.createElement('tr');
tr.dataset.id = row.id;
if (state.selected[row.id]) {
tr.classList.add('selected');
}
// Checkbox cell
var tdCheck = document.createElement('td');
var checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = !!state.selected[row.id];
checkbox.setAttribute('aria-label', 'Select ' + row.name);
tdCheck.appendChild(checkbox);
tr.appendChild(tdCheck);
// Data cells
COLUMNS.forEach(function(col) {
var td = document.createElement('td');
td.dataset.column = col;
td.textContent = col === 'salary' ? '$' + row[col].toLocaleString() : row[col];
tr.appendChild(td);
});
fragment.appendChild(tr);
});
tbody.innerHTML = '';
tbody.appendChild(fragment);
// Update header sort indicators
thead.querySelectorAll('th[data-column]').forEach(function(th) {
th.classList.remove('sort-asc', 'sort-desc');
if (th.dataset.column === state.sortCol) {
th.classList.add('sort-' + state.sortDir);
}
});
// Render pagination buttons
pagination.innerHTML = '';
for (var i = 1; i <= totalPages; i++) {
var btn = document.createElement('button');
btn.textContent = i;
btn.dataset.page = i;
if (i === state.currentPage) btn.classList.add('active');
pagination.appendChild(btn);
}
// Update selection info
var count = Object.keys(state.selected).length;
selectionInfo.textContent = count > 0 ? count + ' row(s) selected' : '';
selectAll.checked = pageData.length > 0 && pageData.every(function(r) { return state.selected[r.id]; });
}
// --- Event Delegation ---
// Sort on header click
thead.addEventListener('click', function(e) {
var th = e.target.closest('th[data-column]');
if (!th) return;
var col = th.dataset.column;
if (state.sortCol === col) {
state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
} else {
state.sortCol = col;
state.sortDir = 'asc';
}
render();
});
// Row selection via checkbox
tbody.addEventListener('change', function(e) {
if (e.target.type !== 'checkbox') return;
var row = e.target.closest('tr');
var id = Number(row.dataset.id);
if (e.target.checked) {
state.selected[id] = true;
} else {
delete state.selected[id];
}
render();
});
// Select all
selectAll.addEventListener('change', function() {
var checked = selectAll.checked;
var start = (state.currentPage - 1) * PAGE_SIZE;
var pageData = state.filtered.slice(start, start + PAGE_SIZE);
pageData.forEach(function(row) {
if (checked) {
state.selected[row.id] = true;
} else {
delete state.selected[row.id];
}
});
render();
});
// Inline editing on double-click
tbody.addEventListener('dblclick', function(e) {
var td = e.target.closest('td[data-column]');
if (!td || td.getAttribute('contenteditable') === 'true') return;
var col = td.dataset.column;
var row = td.closest('tr');
var id = Number(row.dataset.id);
var original = state.data.find(function(r) { return r.id === id; });
td.setAttribute('contenteditable', 'true');
td.focus();
td.addEventListener('blur', function handler() {
td.removeAttribute('contenteditable');
var newValue = td.textContent.replace(/^\$/, '').replace(/,/g, '');
if (col === 'salary') {
newValue = parseInt(newValue, 10);
if (isNaN(newValue)) newValue = original[col];
}
original[col] = newValue;
td.removeEventListener('blur', handler);
render();
});
td.addEventListener('keydown', function(ke) {
if (ke.key === 'Enter') {
ke.preventDefault();
td.blur();
}
if (ke.key === 'Escape') {
td.textContent = col === 'salary' ? '$' + original[col].toLocaleString() : original[col];
td.blur();
}
});
});
// Filter input
filterInput.addEventListener('input', function() {
state.filter = filterInput.value;
state.currentPage = 1;
render();
});
// Pagination
pagination.addEventListener('click', function(e) {
var btn = e.target.closest('button[data-page]');
if (!btn) return;
state.currentPage = Number(btn.dataset.page);
render();
});
// Initial render
render();
})();
</script>
</body>
</html>
This example demonstrates:
createElementandDocumentFragmentfor efficient row renderingclassListfor sort indicators and selection highlightingdatasetfor storing row IDs, column names, and page numbers- Event delegation via
closestonthead,tbody, and pagination contenteditablewith blur/keydown handlers for inline editinginsertAdjacentHTMLalternative (we useinnerHTML = ''sparingly for the controlled tbody reset)
Common Issues and Troubleshooting
1. "TypeError: el.remove is not a function"
This occurs in Internet Explorer, which does not support Element.prototype.remove(). If you must support IE, use the removeChild pattern or add a polyfill:
// Polyfill for IE
if (!Element.prototype.remove) {
Element.prototype.remove = function() {
if (this.parentNode) {
this.parentNode.removeChild(this);
}
};
}
2. querySelectorAll Returns Empty Despite Elements Existing
Your script is executing before the DOM is ready. Either move your <script> tag to the bottom of <body> or use DOMContentLoaded:
document.addEventListener('DOMContentLoaded', function() {
var items = document.querySelectorAll('.item'); // Now finds them
});
3. Event Listeners Lost After innerHTML Assignment
Setting innerHTML destroys and recreates all child elements. Any event listeners attached to those children are gone. Use event delegation on a parent that is not being replaced, or use insertAdjacentHTML instead.
// PROBLEM: listeners on .btn are lost
container.innerHTML += '<div class="new-item">Added</div>';
// SOLUTION: use insertAdjacentHTML
container.insertAdjacentHTML('beforeend', '<div class="new-item">Added</div>');
4. MutationObserver Callback Fires in Infinite Loop
If your MutationObserver callback modifies the DOM it is observing, it triggers itself. Disconnect the observer before making changes, then reconnect:
var observer = new MutationObserver(function(mutations) {
observer.disconnect();
// Make DOM changes safely
target.classList.add('processed');
// Reconnect
observer.observe(target, config);
});
5. getBoundingClientRect Returns Zero for Hidden Elements
Elements with display: none have no layout box. getBoundingClientRect() returns zeroes for all properties. Use visibility: hidden instead if you need to measure an element before showing it.
Best Practices
Use event delegation over individual listeners. Attach one listener to a parent container and use
closest()ormatches()to identify the target. This handles dynamically added elements automatically and uses far less memory.Batch DOM mutations with DocumentFragment. Every direct DOM insertion can trigger a reflow. Build your element tree in a fragment, then insert once.
Prefer classList and dataset over manual string manipulation.
className += ' active'is error-prone (double spaces, duplicates).classList.add('active')is idempotent and correct.Never use innerHTML with unsanitized user input. This is the most common XSS vector in front-end code. Use
textContentfor user data. If you must insert HTML, sanitize it server-side or use a library like DOMPurify.Separate reads and writes to avoid layout thrashing. Reading layout properties (
offsetHeight,getBoundingClientRect) forces the browser to calculate layout. If you then write a style change and read again, you force another layout. Batch all reads first, then all writes.Use the
<template>element for repeated structures. It is cleaner thancreateElementchains for complex markup, and the browser parses it once.Disconnect observers when you are done.
MutationObserver,ResizeObserver, andIntersectionObservershould be disconnected when their target is removed or when you no longer need them. Failing to do so causes memory leaks.Prefer CSS transitions over JavaScript animation for simple effects. Toggle a class with
classListand let CSS handle the transition. It runs on the compositor thread and does not block the main thread.