JavaScript DOM and Events: Selection, Updates, Delegation, and Performance
The DOM โ Document Object Model โ is the browserโs live object tree representing your page, and the runtime API JavaScript uses to read and change what users see. Every framework (React, Angular, Vue) is ultimately a machine for updating the DOM; knowing the layer beneath them is what separates โI use the frameworkโ from โI understand what my app doesโ โ and itโs indispensable for debugging, performance work, and the many tasks where a framework is overkill.
This guide covers modern DOM work: selection, safe updates, the event system with bubbling and delegation, and the performance model that explains janky pages.
Selecting Elements
Two methods cover modern selection โ both taking CSS selectors, the same language you style with:
const form = document.querySelector("#checkout"); // first match or nullconst buttons = document.querySelectorAll(".buy-btn"); // ALL matches, NodeListconst row = table.querySelector("tr.selected"); // scoped to a subtree
for (const btn of buttons) { ... } // NodeList is iterable[...buttons].filter((b) => !b.disabled); // spread into a real arrayFacts that prevent early confusion: querySelector returns null on no match โ the classic Cannot read properties of null means your selector missed (or ran before the element existed; module scripts and defer run after parsing, which mostly retires the old DOMContentLoaded wrapper). querySelectorAll returns a static NodeList โ a snapshot, not a live view โ and itโs array-like: iterable, with forEach, but needing a spread for the full method set. Legacy selectors (getElementById, getElementsByClassName) still work โ the latter returns live collections with looping-while-mutating hazards; querySelector* is the consistent modern default.
Reading and Updating Elements
const price = document.querySelector("#price");
// text โ safe, alwaysprice.textContent = `โน${(paise / 100).toLocaleString("en-IN")}`;
// attributes and propertiesinput.value // current value (property โ live)input.getAttribute("value") // the HTML attribute (initial value!) โ not the sameimg.src = "/hero.webp";btn.disabled = true;link.dataset.orderId // data-order-id="..." โ your data on elements
// classes โ the styling workhorsecard.classList.add("active");card.classList.remove("hidden");card.classList.toggle("expanded");card.classList.contains("active")
// inline style โ for computed values; classes for everything elsebar.style.width = `${percent}%`;The habit that matters most: state changes via classList, appearance via CSS. JS toggles a class; the stylesheet decides what โactiveโ looks like. Scattering style.color = ... through logic couples behavior to presentation and fights the cascade.
The innerHTML rule
el.textContent = userInput; // SAFE โ rendered as literal text, alwaysel.innerHTML = userInput; // DANGEROUS โ parsed as HTML: <img onerror=...> runsel.innerHTML = `<b>${TRUSTED_CONSTANT}</b>`; // acceptable: fully controlled markupinnerHTML with anything user-influenced is XSS โ the attackerโs string becomes executing markup. The discipline is binary: textContent for data, innerHTML only for markup you wrote entirely yourself. (Frameworks escape by default โ one of their quiet security services; the modern setHTML() sanitizer API is arriving as a middle path.)
Creating and removing
const li = document.createElement("li");li.className = "cart-item";li.textContent = item.name; // safe insertion of datalist.append(li); // also: prepend, before, afterli.remove();
// batch inserts: build detached, attach onceconst frag = document.createDocumentFragment();for (const item of items) frag.append(renderItem(item));list.append(frag); // one DOM insertion, not NcreateElement + textContent + append is the safe construction triad. The fragment pattern previews the performance section: touching the live DOM is the expensive part, so batch it.
Events: Listening, Bubbling, Delegation
button.addEventListener("click", (e) => { e.preventDefault(); // stop the default action (nav, submit) addToCart(e.currentTarget.dataset.sku);});You know the function-value mechanics โ pass the handler, donโt call it โ and the this rules (prefer e.currentTarget and arrows). The event object carries the details: target (the innermost element the event happened on), currentTarget (the element this listener is attached to), type-specific data (key for keyboard, coordinates for pointer events), and control methods.
The systemโs big idea is bubbling: an event fires on its target, then travels up through every ancestor, offering each a chance to handle it:
click on <button> inside <li> inside <ul>: button handlers โ li handlers โ ul handlers โ ... โ documentBubbling enables the DOMโs most important pattern โ event delegation: one listener on a stable ancestor handles events for any number of (present or future) descendants:
list.addEventListener("click", (e) => { const btn = e.target.closest("button.remove"); // which descendant, if any? if (!btn || !list.contains(btn)) return; removeItem(btn.dataset.id);});Why this beats a listener per button: rows added later work automatically (no re-wiring after every render), memory stays flat regardless of list size, and cleanup is one listener instead of bookkeeping hundreds. closest(selector) โ walk up from the click target to the matching element โ is delegationโs essential partner, handling clicks that land on icons inside the button. Dynamic lists + delegation is the idiomatic pairing; frameworks do a version of this internally.
Supporting cast: stopPropagation() halts bubbling (use sparingly โ it breaks other delegated listeners, including analytics; usually a design smell), { once: true } auto-removes after first fire, and removeEventListener requires the same function reference โ anonymous arrows canโt be removed, so delegation or AbortController-based removal ({ signal }) handles teardown:
const ac = new AbortController();el.addEventListener("scroll", onScroll, { signal: ac.signal });ac.abort(); // removes every listener wired to itThe same controller pattern as fetch โ one abort tears down a whole featureโs listeners. Forgotten listeners on removed-and-recreated elements are the DOMโs classic memory leak, and delegation/signal are the structural fixes.
The Performance Model: Why Pages Jank
Three facts explain most DOM performance problems:
1. DOM access crosses a boundary. JS-to-DOM calls are cheap individually, expensive by the thousand. Batch reads and writes; build fragments; keep references instead of re-querying in loops.
2. Layout thrash โ the big one. The browser recalculates geometry lazilyโฆ unless you ask for geometry (offsetHeight, getBoundingClientRect) right after changing it, forcing a synchronous recalculation. In a loop, thatโs a full layout per iteration:
// THRASH: write, read, write, read... forced layout every passfor (const bar of bars) { bar.style.width = bar.offsetWidth + 10 + "px";}// FIX: phase it โ all reads, then all writesconst widths = [...bars].map((b) => b.offsetWidth);bars.forEach((b, i) => { b.style.width = widths[i] + 10 + "px"; });Read-then-write phasing is the whole cure. For animation-timed work, requestAnimationFrame schedules your mutation just before paint โ the event-loop slot built for it.
3. High-frequency events need damping. scroll, pointermove, input fire dozens of times per second; heavy handlers on them freeze the single thread. The standard tools: debounce (act after the burst pauses โ search-as-you-type), throttle (act at most every N ms โ scroll position updates), both closure-built utilities; and for visibility work, replace scroll math entirely with IntersectionObserver โ the browser tells you when elements enter the viewport (lazy loading, infinite scroll, analytics), off the hot path.
A Complete Micro-Example: Interactive List, No Framework
Everything in this guide, composed into the kind of widget that genuinely doesnโt need React โ a filterable task list with add and remove:
const form = document.querySelector("#add-form");const input = form.querySelector("input[name=title]");const filter = document.querySelector("#filter");const list = document.querySelector("#tasks");
function renderTask(title) { const li = document.createElement("li"); li.className = "task"; li.textContent = title; // safe with user data const btn = document.createElement("button"); btn.className = "remove"; btn.textContent = "โ"; li.append(btn); return li;}
form.addEventListener("submit", (e) => { e.preventDefault(); const title = input.value.trim(); if (!title) return; list.append(renderTask(title)); form.reset(); input.focus();});
list.addEventListener("click", (e) => { // one delegated listener const btn = e.target.closest("button.remove"); if (btn) btn.closest("li").remove();});
filter.addEventListener("input", () => { // cheap enough not to debounce const q = filter.value.toLowerCase(); for (const li of list.querySelectorAll("li.task")) { li.hidden = !li.textContent.toLowerCase().includes(q); }});Notice the guideโs principles operating: submit (not button-click) for Enter-key support; textContent for user data; delegation so removals need no per-row wiring; hidden toggling instead of rebuild-the-list; construction via createElement. Thirty lines, no dependencies, and โ the deeper point โ this is what frameworks generate and manage for you. When state gets complex enough that keeping DOM and data in sync by hand breeds bugs, thatโs the moment frameworks earn their weight; now youโll recognize it.
Frequently Asked Questions
Should I manipulate the DOM directly if I use React/Angular? Inside framework-managed trees โ no; youโd fight the frameworkโs own updates (Angularโs guide covers its escape hatches). But the knowledge stays load-bearing: debugging is DOM inspection, refs/escape hatches exist because real apps need them (focus, measurement, third-party widgets), and plenty of scripts, extensions, and small pages deserve no framework at all.
target vs currentTarget โ the one-liner? target = where the event happened (innermost); currentTarget = where this listener is attached. Delegation reads target (plus closest); styling/self-reference wants currentTarget.
How do forms fit in? Listen for submit on the form (not click on the button โ Enter-key submission matters), preventDefault if handling in JS, read values via new FormData(form) or input .value, and validate before sending with fetch. The input event tracks per-keystroke changes; change fires on commit.
What about Shadow DOM and web components? A parallel encapsulation system (component-scoped DOM and styles) underlying custom elements. Worth knowing exists โ events retarget at shadow boundaries, which occasionally explains a surprising e.target โ but itโs a specialization, not week-one material.
Is jQuery knowledge transferable? Conceptually, entirely โ selection, events, delegation all originated or matured there. The modern APIs above are the standard-library absorption of jQueryโs ideas; translation is mostly mechanical ($(sel) โ querySelectorAll, .on("click", sel, fn) โ delegation with closest).
Accessibility Is Part of DOM Work
One habit-forming note before the close: DOM manipulation carries accessibility responsibility that CSS-only thinking misses. When JS shows/hides content, use mechanisms assistive technology understands โ the hidden attribute or display: none remove elements from the accessibility tree correctly, while opacity: 0 leaves invisible content focusable and announced. Interactive elements you create should be genuinely interactive elements: a <button> you createElement gets keyboard focus, Enter/Space activation, and screen-reader semantics free; a click-handled <div> gets none of them and needs tabindex, role, and key handlers bolted on to imitate what the button had natively. And dynamic updates that users must notice (form errors, async results) may need aria-live regions so screen readers announce them. The rule of least effort: use the semantic element, and accessibility mostly ships itself โ the expensive path is faking semantics with generic tags.
Self-Check
// 1 โ why might this log null, and what are two fixes?<script>const el = document.querySelector("#app");</script> (in <head>)
// 2 โ user data lands in the page. Which lines are XSS-safe?a) el.textContent = user.bio;b) el.innerHTML = user.bio;c) el.innerHTML = "<hr>";d) el.insertAdjacentHTML("beforeend", `<p>${user.bio}</p>`);
// 3 โ 500-row table, each row needs a delete button. Listeners: how many, where?
// 4 โ this search box lags on every keystroke. Two independent fixes?input.addEventListener("input", async (e) => { const res = await fetch(`/search?q=${e.target.value}`); render(await res.json());});Answers: (1) The script ran before the parser reached #app โ use type="module" or defer (or move the script to the end of body). (2) a and c only โ b and d parse user data as HTML; dโs template-literal interpolation is the sneaky one. (3) One, on the table (or tbody), delegating via e.target.closest("button.delete") โ works for rows added later, too. (4) Debounce the handler so it fires after typing pauses, and abort the previous request with AbortController so stale responses canโt overwrite fresh ones โ the race from the fetch guide. (Encoding via URLSearchParams is a worthy third.)
That closes the JavaScript seriesโ arc: how the language runs, what itโs made of, the async model, and now the surface where it meets users. If you followed the whole path, you have something most JS developers assemble only after years of debugging: a mechanical model of the language where nothing โ not this, not coercion, not promise ordering, not jank โ is magic. Frameworks come and go; this layer is the part that compounds.