Selecting elements

document.querySelector('.card');        // first match
document.querySelectorAll('.card');     // NodeList of all matches
document.getElementById('main');        // single element by id
document.getElementsByClassName('card');// live HTMLCollection
document.getElementsByTagName('li');    // live HTMLCollection

querySelector/querySelectorAll accept any CSS selector, which makes them the default choice — you can target things like '.card:nth-child(2)' or 'input[type="email"]' directly.

Live vs. static collections: getElementsByClassName/getElementsByTagName return live collections — they update automatically as the DOM changes. querySelectorAll returns a static NodeList — a snapshot at the time you called it. This distinction causes subtle bugs if you’re not aware of it (e.g. looping over a live collection while removing elements from it).

querySelectorAll returns a NodeList, not an array — it has .forEach but not .map/.filter. Convert with Array.from(nodeList) or [...nodeList] if you need array methods.


Traversing the DOM

el.parentElement;
el.children;              // element children only
el.childNodes;             // includes text/comment nodes
el.nextElementSibling;
el.previousElementSibling;
el.closest('.container');  // nearest ancestor (or self) matching selector
el.matches('.active');     // does this element match a selector?

closest() and matches() are especially useful for event delegation (below).


Reading & modifying content

el.textContent = 'Hello';   // plain text, safe from HTML injection
el.innerHTML = '<b>Hi</b>'; // parses as HTML — sanitize any untrusted input first
el.innerText;                // like textContent but respects rendered styling (slower, layout-aware)

Prefer textContent over innerHTML unless you specifically need to insert markup — innerHTML is a common XSS vector if the content comes from user input.


Attributes & properties

el.getAttribute('data-id');
el.setAttribute('data-id', '42');
el.removeAttribute('disabled');
el.hasAttribute('hidden');
 
el.dataset.id;               // reads data-id
el.dataset.userName;         // reads data-user-name (camelCase <-> kebab-case)

dataset is the ergonomic way to work with data-* attributes — no manual string parsing.

Note: some HTML attributes and DOM properties diverge. el.value (a form input’s current value) and el.getAttribute('value') (the attribute set in HTML) can differ once the user types.


Classes & styles

el.classList.add('active');
el.classList.remove('active');
el.classList.toggle('active');
el.classList.toggle('active', someBoolean); // force add/remove based on condition
el.classList.contains('active');
 
el.style.color = 'red';           // inline style — use sparingly
el.style.setProperty('--gap', '8px'); // set a CSS custom property

Prefer toggling classes over setting el.style directly — keeps styling in CSS where it belongs, and plays nicer with your design tokens.


Creating & inserting elements

const div = document.createElement('div');
div.textContent = 'New card';
div.className = 'card';
 
parent.appendChild(div);
parent.prepend(div);
parent.append(div, anotherEl);       // append() accepts multiple nodes/strings
el.before(newEl);
el.after(newEl);
el.replaceWith(newEl);
el.remove();

For inserting HTML strings directly:

el.insertAdjacentHTML('beforeend', '<li>Item</li>');

insertAdjacentHTML positions: 'beforebegin', 'afterbegin', 'beforeend', 'afterend' — before/after the element itself, or just inside its first/last child position.

Performance note: if you’re inserting many elements in a loop, build them into a DocumentFragment first and append the fragment once — this avoids triggering a reflow/repaint on every single insertion.

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

Events

el.addEventListener('click', (event) => {
  console.log(event.target); // the actual element clicked
});
 
el.removeEventListener('click', handlerFn); // must be the same function reference

Event delegation — attach one listener to a parent instead of many to children, and check what was actually clicked:

list.addEventListener('click', (event) => {
  const item = event.target.closest('.list-item');
  if (!item) return;
  // handle click on item
});

This is more efficient for long/dynamic lists and automatically covers elements added later.

Useful event methods:

event.preventDefault();   // stop default browser behavior (e.g. form submit, link navigation)
event.stopPropagation();  // stop the event from bubbling further up

Event flow: events capture down from window to the target, then bubble back up. addEventListener listens on the bubble phase by default; pass { capture: true } as a third argument to listen during capture instead.


Observing changes

For reacting to DOM changes that don’t come from your own code (e.g. content injected by a third-party script, or watching for an element to appear):

const observer = new MutationObserver((mutations) => {
  mutations.forEach(m => console.log(m));
});
observer.observe(targetEl, { childList: true, subtree: true, attributes: true });
observer.disconnect(); // stop observing

For reacting to an element entering/leaving the viewport (common for lazy-loading, infinite scroll, animations on scroll):

const io = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) { /* element is visible */ }
  });
});
io.observe(targetEl);

Quick reference: common gotchas

  • querySelectorAll result is static; getElementsByClassName result is live.
  • innerHTML parses HTML — never insert untrusted content that way.
  • el.style.x only reads/sets inline styles, not computed/CSS-file styles. Use getComputedStyle(el) to read the actual rendered value.
  • Removing an event listener requires the exact same function reference used to add it — anonymous inline functions can’t be removed.
  • Inserting many nodes one at a time in a loop is slow; batch with a DocumentFragment.