Can you write this from memory?
Select the element with id "main-title".
In JavaScript, frameworks handle the DOM for you until they don't. Debugging "why didn't this click handler fire?", integrating a third-party widget, writing a browser extension, or patching a legacy page often means working directly with the DOM.
This page is a practical drill + reference for the DOM APIs you'll actually use: selecting, creating, inserting, updating, and handling events safely and efficiently.
The DOM (Document Object Model) is the browser's live representation of your HTML. Every element is a node in a tree structure. JavaScript can read and modify this tree, and the browser re-renders when it changes.
DOM operations are expensive compared to plain JS. Minimize direct DOM access by batching reads and writes.
Modern DOM selection uses CSS selectors:
// Single element (first match)
const btn = document.querySelector(".submit-btn");
// Multiple elements (static NodeList)
const items = document.querySelectorAll(".list-item");
// Always guard against null
const panel = document.querySelector("#panel");
if (!panel) return;
Why querySelector over getElementById? For a printable reference of all DOM selection and manipulation methods, see the JavaScript DOM manipulation cheat sheet.
- Consistent API (always CSS selectors)
- Works on any element, not just document
- More expressive:
#id,.class,[data-attr],:not()
Watch out: If your script runs before the DOM is ready, elements won't exist yet. Either use defer on your script tag or wait for DOMContentLoaded.
Once you have an element, you often need to find related elements:
// Parents and children
element.parentElement // direct parent
element.children // child elements (HTMLCollection)
element.firstElementChild // first child element
element.nextElementSibling // next sibling element
// closest() - walk UP the tree (essential for delegation)
const card = button.closest(".card"); // finds nearest ancestor matching selector
// matches() - test if element matches selector
if (element.matches(".active")) { ... }
The closest() pattern is critical for event delegation. It finds the ancestor you care about regardless of which child was actually clicked.
Modern insertion methods are cleaner than the old appendChild:
// Create
const li = document.createElement("li");
li.textContent = "New item";
li.classList.add("list-item");
li.dataset.id = "123";
// Insert (relative to target element)
parent.append(li); // as last child
parent.prepend(li); // as first child
sibling.before(li); // before sibling
sibling.after(li); // after sibling
// Remove
li.remove(); // removes from DOM
For trusted HTML strings, use insertAdjacentHTML:
// Safer than el.innerHTML += "..."
el.insertAdjacentHTML("beforeend", "<li>New item</li>");
Positions: "beforebegin", "afterbegin", "beforeend", "afterend"
This is a security-critical distinction:
// SAFE: treats input as plain text (escapes HTML automatically)
element.textContent = userInput;
// RISKY: parses input as HTML
// Only use with trusted content, never with user input
element.innerHTML = trustedHtmlString;
Rule: Use textContent for user-provided content. For HTML, either sanitize first with a library like DOMPurify, or construct elements with createElement and textContent.
Side effect of innerHTML: Setting it destroys all child nodes (and their event listeners) and creates new ones. This is why event delegation matters.
The classList API is cleaner than manipulating className strings:
element.classList.add("active", "visible"); // add one or more
element.classList.remove("hidden"); // remove
element.classList.toggle("expanded"); // add if missing, remove if present
element.classList.toggle("dark", isDark); // force on/off based on boolean
element.classList.contains("active"); // check
element.classList.replace("old", "new"); // swap
Why it matters: No more string splitting and rejoining. No accidental partial matches. Clean, readable code.
Instead of attaching listeners to every item:
// BAD: One listener per item (scales poorly, breaks on dynamic items)
items.forEach(item => item.addEventListener("click", handler));
// GOOD: One listener on parent, handle all children
list.addEventListener("click", (e) => {
const target = e.target as Element; // TypeScript: cast EventTarget
const item = target.closest("li[data-id]");
if (!item) return; // clicked something else
// item is the <li> we care about
handleItemClick(item.dataset.id);
});
Why delegation works:
- Events bubble up from target to ancestors
- closest() finds the right element regardless of click position
- Works for dynamically added elements automatically
- One listener uses less memory than hundreds
The pattern: Attach to stable parent -> use closest() to find target -> guard with if (!item) return. This listener-on-parent pattern is an application of the Observer design pattern, where one handler reacts to events from many sources.
Form submissions and link clicks trigger default browser behavior. Use preventDefault() to handle them in JavaScript:
form.addEventListener("submit", (e) => {
e.preventDefault(); // stop page reload
const data = new FormData(form);
submitToAPI(data);
});
link.addEventListener("click", (e) => {
e.preventDefault(); // stop navigation
handleNavigation(link.href);
});
Also useful: stopPropagation() prevents the event from bubbling to parent handlers.
This distinction trips up even experienced developers:
// Attributes (HTML)
el.getAttribute("value"); // initial HTML value
el.setAttribute("data-id", "123");
el.dataset.id = "123"; // shorthand for data-* attributes
// Properties (live JS)
input.value; // current value (what user typed)
input.checked; // current checked state
The gotcha: For form inputs, the attribute is the initial value; the property is the current state. They can diverge after user interaction.
Two common needs: getting CSS styles and getting element dimensions.
Computed styles vs inline styles
// element.style only reads INLINE styles (set via style attribute or JS)
el.style.color; // "" if color comes from CSS
// getComputedStyle reads the ACTUAL computed value
const styles = getComputedStyle(el);
styles.color; // "rgb(0, 0, 0)" - the real value
Element position and size
// getBoundingClientRect: position relative to viewport
const rect = el.getBoundingClientRect();
rect.top; // distance from viewport top
rect.left; // distance from viewport left
rect.width; // element width (including padding, border)
rect.height; // element height
// Dimensions only
el.offsetWidth; // width including padding + border
el.offsetHeight; // height including padding + border
el.clientWidth; // width including padding (no border)
// Scroll position
el.scrollTop; // pixels scrolled from top
el.scrollLeft; // pixels scrolled from left
Layout thrashing happens when you interleave reads and writes:
// BAD: Forces layout recalculation on each iteration
items.forEach(item => {
const height = item.offsetHeight; // READ (triggers layout)
item.style.width = height + "px"; // WRITE (invalidates layout)
});
// GOOD: Batch reads, then batch writes
const heights = items.map(item => item.offsetHeight); // all READs
items.forEach((item, i) => {
item.style.width = heights[i] + "px"; // all WRITEs
});
For async DOM updates such as fetching data before inserting elements, see the async/await page.
For bulk insertions, use DocumentFragment:
const fragment = document.createDocumentFragment();
data.forEach(text => {
const li = document.createElement("li");
li.textContent = text;
fragment.append(li); // no reflow yet
});
ul.append(fragment); // single reflow for all items
| Task | Method |
|---|---|
| Select one | querySelector(selector) |
| Select all | querySelectorAll(selector) |
| Find ancestor | element.closest(selector) |
| Test match | element.matches(selector) |
| Create | document.createElement(tag) |
| Insert | append, prepend, before, after |
| Remove | element.remove() |
| Safe text | element.textContent = text |
| Classes | classList.add/remove/toggle |
| Data attributes | element.dataset.key |
| Stop default | e.preventDefault() |
| Get position | el.getBoundingClientRect() |
| Read CSS | getComputedStyle(el).property |
| Event delegation | parent.addEventListener + closest() |
When to Use JavaScript DOM Manipulation
- Quick prototypes or widgets without a framework.
- Debugging UI bugs where framework abstractions leak.
- Browser extensions, injected scripts, or embedded widgets.
- Legacy codebases where you can't adopt a framework.
- Understanding what React/Vue do under the hood.
Check Your Understanding: JavaScript DOM Manipulation
Add a click handler to a list using event delegation.
Attach one listener to the parent, use event bubbling, and locate the intended item with event.target.closest("li"). This avoids per-item listeners and handles dynamically added items.
What You'll Practice: JavaScript DOM Manipulation
Common JavaScript DOM Manipulation Pitfalls
- querySelector returns null if not found (or DOM not ready)
- innerHTML can introduce XSS if you inject untrusted strings
- Replacing innerHTML removes child nodes and their listeners
- Confusing event.target with event.currentTarget
- Layout thrashing: mixing DOM reads and writes in loops
- Forgetting that NodeList isn't an array (no map/filter directly)
- Using element.style to read CSS (only reads inline styles)
- Forgetting preventDefault() on form submit or link clicks
JavaScript DOM Manipulation FAQ
Why does querySelector sometimes return null?
Either the selector doesn't match anything, or the element isn't in the DOM yet. If your script runs before the DOM is parsed, wait for DOMContentLoaded or load scripts with the defer attribute.
textContent or innerHTML?
Use textContent for untrusted content (user input) to prevent XSS attacks. Only use innerHTML with trusted or properly sanitized HTML.
Why doesn't my delegated click handler work?
Make sure the event type bubbles (click does). Use event.target.closest(".selector") instead of assuming event.target is the button itself. Clicks on child elements bubble up.
What's the difference between attributes and properties?
Attributes are in HTML markup (getAttribute/setAttribute). Properties are live JS values on the element object (element.value). For inputs, the property reflects current state while the attribute reflects initial HTML.
What's the difference between event.target and event.currentTarget?
event.target is the element that triggered the event (clicked element). event.currentTarget is the element the listener is attached to. With delegation, target is the child; currentTarget is the parent.
How do I avoid layout thrashing?
Batch your reads together, then batch your writes. Reading offsetHeight then writing style, then reading again forces the browser to recalculate layout each time. Use requestAnimationFrame or DocumentFragment for batch updates.
NodeList vs HTMLCollection: what's the difference?
querySelectorAll returns a static NodeList (snapshot). getElementsByClassName returns a live HTMLCollection (updates as DOM changes). Both are array-like but not arrays. Use Array.from() or spread to convert.
Why did replacing innerHTML remove my event listeners?
Setting innerHTML destroys all child nodes and creates new ones. Event listeners attached to the old nodes are gone. Use event delegation on a parent that survives, or use textContent/insertAdjacentHTML for safer updates.
Why does element.style not show my CSS styles?
element.style only reads inline styles set via the style attribute or JavaScript. To read computed styles from CSS, use window.getComputedStyle(element).propertyName.
How do I get an element's position and size?
Use getBoundingClientRect() for position relative to viewport (top, left, width, height). For dimensions only, offsetWidth/offsetHeight include borders and padding. For scroll position, use scrollTop/scrollLeft.
What is DOM manipulation in JavaScript?
DOM manipulation means using JavaScript to read or change HTML elements on a page. Common operations include selecting elements (`querySelector`), changing content (`textContent`, `innerHTML`), modifying styles or classes (`classList`), and listening for events (`addEventListener`). The DOM is a tree structure that the browser builds from your HTML.
JavaScript DOM Manipulation Syntax Quick Reference
const btn = document.querySelector(".submit-btn");
if (!btn) return; // selector didn't matchconst li = document.createElement("li");
li.textContent = "New item";
document.querySelector("ul")?.append(li);const el = document.querySelector("#panel");
el?.classList.toggle("is-open");const out = document.querySelector("#output");
const userInput = "<script>alert(1)</script>";
if (out) out.textContent = userInput; // safer than innerHTMLconst list = document.querySelector(".items");
list?.addEventListener("click", (e) => {
const target = e.target as Element;
const item = target.closest("li[data-id]");
if (!item) return;
console.log(item.dataset.id);
});form.addEventListener("submit", (e) => {
e.preventDefault(); // stop page reload
const data = new FormData(form);
submitForm(data);
});button.addEventListener("click", (e) => {
const card = (e.target as Element).closest(".card");
const isActive = card?.matches(".active");
});const frag = document.createDocumentFragment();
items.forEach(text => {
const li = document.createElement("li");
li.textContent = text;
frag.append(li);
});
ul.append(frag); // single reflowconst rect = el.getBoundingClientRect();
console.log(rect.top, rect.left, rect.width, rect.height);const styles = getComputedStyle(el);
const color = styles.color; // works for CSS styles
// el.style.color only reads inline stylesJavaScript DOM Manipulation Sample Exercises
Select the first element with class "card".
document.querySelector(".card")Select the first `<button>` element.
document.querySelector("button")Select ALL elements with class "item".
document.querySelectorAll(".item")+ 44 more exercises