Technology  /  JavaScript

๐ŸŸจ JavaScript 15 guides ยท updated 2026

Closures, the event loop, promises, and modern ES syntax โ€” the language of the web explained the way it actually behaves at runtime.

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 null
const buttons = document.querySelectorAll(".buy-btn"); // ALL matches, NodeList
const 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 array

Facts 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, always
price.textContent = `โ‚น${(paise / 100).toLocaleString("en-IN")}`;
// attributes and properties
input.value // current value (property โ€” live)
input.getAttribute("value") // the HTML attribute (initial value!) โ€” not the same
img.src = "/hero.webp";
btn.disabled = true;
link.dataset.orderId // data-order-id="..." โ€” your data on elements
// classes โ€” the styling workhorse
card.classList.add("active");
card.classList.remove("hidden");
card.classList.toggle("expanded");
card.classList.contains("active")
// inline style โ€” for computed values; classes for everything else
bar.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, always
el.innerHTML = userInput; // DANGEROUS โ€” parsed as HTML: <img onerror=...> runs
el.innerHTML = `<b>${TRUSTED_CONSTANT}</b>`; // acceptable: fully controlled markup

innerHTML 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 data
list.append(li); // also: prepend, before, after
li.remove();
// batch inserts: build detached, attach once
const frag = document.createDocumentFragment();
for (const item of items) frag.append(renderItem(item));
list.append(frag); // one DOM insertion, not N

createElement + 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 โ†’ ... โ†’ document

Bubbling 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 it

The 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 pass
for (const bar of bars) {
bar.style.width = bar.offsetWidth + 10 + "px";
}
// FIX: phase it โ€” all reads, then all writes
const 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.