I've lost count of the number of times I've watched a junior developer reach for jQuery — or even React — just to toggle a class on a button. Don't get me wrong, frameworks are great. But if you don't understand what's happening underneath, you'll spend hours debugging problems that would take seconds if you knew the DOM.
The DOM (Document Object Model) is the browser's live representation of your HTML. Every element, every attribute, every piece of text — it's all a node in a giant tree structure. And JavaScript gives you full power to read, create, modify, and destroy any part of it.
Let's get into the stuff that actually matters.
Selecting Elements: Stop Using getElementById for Everything
Back in 2010, document.getElementById() was king. It's still fast, but there are much better options now. The two methods you should use 95% of the time are querySelector and querySelectorAll.
// Select the first element that matches
const hero = document.querySelector('.hero-section');
// Select ALL matching elements (returns a NodeList)
const cards = document.querySelectorAll('.card');
// You can use any CSS selector — get creative
const activeLink = document.querySelector('nav a.active');
const thirdItem = document.querySelector('ul li:nth-child(3)');
Here's the thing most tutorials skip: querySelectorAll returns a NodeList, not an array. You can use forEach on it, but you can't use map, filter, or reduce directly. If you need array methods, convert it first:
const cards = document.querySelectorAll('.card');
// Option 1: Spread operator
const cardsArray = [...cards];
// Option 2: Array.from()
const cardsArray2 = Array.from(cards);
// Now you can use array methods
const visibleCards = cardsArray.filter(card =>
!card.classList.contains('hidden')
);
Performance tip: If you're selecting by ID, getElementById() is still marginally faster. But unless you're running selections inside a tight loop with thousands of iterations, the difference is negligible. Use querySelector for consistency.
Scoped Selections
One pattern I use constantly: calling querySelector on a specific element instead of document. This scopes the search to that element's descendants only.
const sidebar = document.querySelector('.sidebar');
const sidebarLinks = sidebar.querySelectorAll('a');
// Only finds links INSIDE .sidebar, not the entire page
This is especially useful when you have repeated components on a page. Don't search the whole document when you don't need to.
Creating and Removing Elements
Creating DOM elements by hand feels verbose compared to JSX or template literals, but understanding this process is essential. Here's the standard pattern:
// Create an element
const card = document.createElement('div');
card.className = 'card';
card.setAttribute('data-id', '42');
// Add content
const title = document.createElement('h3');
title.textContent = 'New Card Title';
card.appendChild(title);
// Add it to the page
document.querySelector('.card-grid').appendChild(card);
That's a lot of code for one card. In my experience, innerHTML with template literals is more practical for anything beyond a simple element:
const cardGrid = document.querySelector('.card-grid');
cardGrid.innerHTML += `
<div class="card" data-id="42">
<h3>New Card Title</h3>
<p>Card description goes here</p>
<button class="btn">View Details</button>
</div>
`;
Warning: using innerHTML += on a container will re-parse all existing HTML inside it. That means any event listeners on child elements get destroyed. For adding elements without nuking existing ones, use insertAdjacentHTML instead:
cardGrid.insertAdjacentHTML('beforeend', `
<div class="card" data-id="42">
<h3>New Card Title</h3>
<p>Card description goes here</p>
</div>
`);
The four positions for insertAdjacentHTML are beforebegin, afterbegin, beforeend, and afterend. I prefer beforeend for most appending tasks — it does what appendChild does but with HTML strings.
Removing Elements
Removing elements used to be awkward. You had to select the parent, then call removeChild. Modern JavaScript has a much cleaner way:
// The old way (still works, just ugly)
const element = document.querySelector('.old-card');
element.parentNode.removeChild(element);
// The modern way
document.querySelector('.old-card').remove();
Clean. Simple. The .remove() method works in all modern browsers. If you need to support IE 11... well, you have bigger problems.
Event Delegation: The Pattern That Changes Everything
This is the single most important DOM pattern you'll ever learn. I'm not exaggerating.
Let's say you have a list of 100 items, and each one has a delete button. The naive approach is to attach 100 event listeners:
// DON'T do this
document.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', handleDelete);
});
// 100 listeners = 100 functions in memory
That's wasteful. And worse — if you dynamically add new items, those new buttons won't have listeners. You'd need to re-attach them every time.
Event delegation fixes both problems. You attach one listener to the parent, and use the event's target to figure out what was clicked:
document.querySelector('.item-list').addEventListener('click', (e) => {
// Check if a delete button (or its child) was clicked
const deleteBtn = e.target.closest('.delete-btn');
if (!deleteBtn) return;
const item = deleteBtn.closest('.item');
const itemId = item.dataset.id;
console.log(`Deleting item ${itemId}`);
item.remove();
});
The closest() method is the secret weapon here. It walks up the DOM tree from the clicked element and returns the nearest ancestor matching the selector. If the user clicks on an icon inside the delete button, e.target would be the icon, not the button. closest('.delete-btn') handles that gracefully.
Why I love event delegation: One listener instead of hundreds. Works with dynamically added elements automatically. Easier to debug. There's almost no reason NOT to use this pattern for repeated UI components.
Modifying Styles and Classes
Please don't set styles directly with element.style.backgroundColor = 'red' unless you absolutely have to. It creates inline styles that are hard to override and impossible to maintain.
Instead, use classList:
const modal = document.querySelector('.modal');
modal.classList.add('visible');
modal.classList.remove('hidden');
modal.classList.toggle('active');
modal.classList.replace('old-class', 'new-class');
// Check if a class exists
if (modal.classList.contains('visible')) {
console.log('Modal is showing');
}
Define your styles in CSS where they belong. Toggle classes with JavaScript. This separation of concerns will save you hours of debugging.
When Inline Styles Make Sense
There are exceptions. Dynamic values that can't be expressed in CSS classes — like positioning an element based on mouse coordinates or setting a progress bar width — absolutely belong as inline styles:
// This is fine — the value is dynamic
progressBar.style.width = `${percentComplete}%`;
// Even better: use CSS custom properties
element.style.setProperty('--mouse-x', `${e.clientX}px`);
element.style.setProperty('--mouse-y', `${e.clientY}px`);
Performance Tips That Actually Matter
Batch Your DOM Operations
Every time you modify the DOM, the browser might need to recalculate layouts. This is called a "reflow," and it's expensive. The biggest mistake I see is modifying the DOM in a loop:
// Slow: causes a reflow on every iteration
items.forEach(item => {
const div = document.createElement('div');
div.textContent = item.name;
container.appendChild(div); // reflow!
});
// Fast: build everything, then insert once
const fragment = document.createDocumentFragment();
items.forEach(item => {
const div = document.createElement('div');
div.textContent = item.name;
fragment.appendChild(div); // no reflow
});
container.appendChild(fragment); // single reflow
DocumentFragment is an invisible container that doesn't exist in the DOM. You can build your entire subtree inside it, then insert it all at once. One reflow instead of a hundred.
Avoid Layout Thrashing
Layout thrashing happens when you read a layout property (like offsetHeight) and then immediately write to the DOM, forcing the browser to recalculate in between:
// Layout thrashing — don't do this
elements.forEach(el => {
const height = el.offsetHeight; // read (forces layout)
el.style.height = height * 2 + 'px'; // write (invalidates layout)
});
// Better: read all first, then write all
const heights = elements.map(el => el.offsetHeight);
elements.forEach((el, i) => {
el.style.height = heights[i] * 2 + 'px';
});
Use textContent Over innerHTML When Possible
If you're just setting text (no HTML), always prefer textContent. It's faster because the browser doesn't need to parse HTML, and it's safer because it doesn't create XSS vulnerabilities. innerHTML will execute any <script> tags or event handlers embedded in the string.
Real-World Pattern: Building a Dynamic List
Let me pull everything together with a practical example — a todo list that uses all the patterns we've covered:
const form = document.querySelector('#todo-form');
const input = document.querySelector('#todo-input');
const list = document.querySelector('#todo-list');
// Event delegation on the list
list.addEventListener('click', (e) => {
const deleteBtn = e.target.closest('[data-action="delete"]');
if (deleteBtn) {
deleteBtn.closest('.todo-item').remove();
return;
}
const toggleBtn = e.target.closest('[data-action="toggle"]');
if (toggleBtn) {
toggleBtn.closest('.todo-item').classList.toggle('completed');
}
});
// Add new items
form.addEventListener('submit', (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
list.insertAdjacentHTML('beforeend', `
<li class="todo-item">
<span data-action="toggle">${text}</span>
<button data-action="delete">×</button>
</li>
`);
input.value = '';
input.focus();
});
No framework. No library. Just clean, efficient JavaScript. The list handles an unlimited number of items with exactly two event listeners. New items work immediately because of event delegation. And we're using insertAdjacentHTML so existing items keep their state.
Turn Your HTML Components Into Images
Building UI components with vanilla JS? Export them as sharp PNG or WebP images for documentation or sharing.
Try HTML to PNG Free →Common Mistakes to Avoid
- Running scripts before DOM is ready. Put your
<script>tag before</body>, or usedeferattribute, or wrap code inDOMContentLoaded. - Using innerHTML when textContent will do. It's slower and opens you up to XSS attacks.
- Forgetting that NodeList isn't an Array. Convert with
[...nodeList]before usingmap,filter, etc. - Attaching listeners inside loops without delegation. One parent listener beats a hundred child listeners every time.
- Storing DOM references that go stale. If you remove and re-add elements, cached references to old elements won't work. Query again, or use event delegation.
Wrapping Up
The DOM API isn't glamorous. It doesn't have the marketing buzz of the latest framework. But it's what every framework is built on top of, and understanding it deeply makes you a significantly better developer.
My advice? Build your next small project — a modal, a dropdown, a tab component — without any framework. You'll be surprised how little code it takes, and you'll never feel lost in framework abstractions again.
Master the fundamentals, and the tools on top become trivial.