JavaScript DOM and Events

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

Node.js 22 LTS · Chrome 130

Without a framework, every interaction goes through the DOM API. Used correctly it is direct; used carelessly it produces the classics — “the click does nothing” and “the list grows every time I click”.

Selecting nodes

document.querySelector(".card");        // first match or null
document.querySelectorAll(".card li");  // NodeList, a static snapshot
document.getElementById("app");         // single element, fastest

const form = document.forms.login;      // named form
const input = form.elements.email;      // named control

querySelectorAll returns a static NodeList: nodes added later are not in it. getElementsByClassName and children return live collections, which mutate while you iterate — the usual cause of an infinite loop. Prefer querySelectorAll and for...of.

Check for null before using a query result. A selector that no longer matches after a redesign should fail loudly at that line, not silently deep inside event wiring.

Creating and inserting

const li = document.createElement("li");
li.textContent = "First item";
li.classList.add("item");
li.dataset.id = "42";

list.append(li);        // last child
list.prepend(li);       // first child
li.before(other);       // sibling positions
li.replaceWith(next);
li.remove();

textContent writes text and escapes nothing, because nothing needs escaping. innerHTML parses markup, and concatenating user data into it is the textbook XSS hole:

li.innerHTML = `<b>${userInput}</b>`;   // userInput = <img src=x onerror=alert(1)>
li.textContent = userInput;             // safe

When you genuinely need a markup structure, keep the markup static and fill the values separately:

<template id="row">
  <li class="item"><span class="title"></span><button type="button">Remove</button></li>
</template>
const row = document.querySelector("#row").content.cloneNode(true);
row.querySelector(".title").textContent = book.title;
list.append(row);

Updating in batches

Every style change and insertion can trigger layout. Build off-document, then attach once:

const frag = document.createDocumentFragment();
for (const item of items) {
  const li = document.createElement("li");
  li.textContent = item.title;
  frag.append(li);
}
list.append(frag);

Reading layout (offsetWidth, getBoundingClientRect(), scrollTop) forces a synchronous reflow. Interleaving reads and writes in a loop is the expensive pattern:

// slow: read, write, read, write…
for (const el of els) {
  el.style.width = el.offsetWidth + 10 + "px";
}

// fast: read all, then write all
const widths = els.map((el) => el.offsetWidth);
els.forEach((el, i) => (el.style.width = widths[i] + 10 + "px"));

replaceChildren(frag) is the clean way to swap a list’s contents: one operation, no leftover nodes, no innerHTML = "".

Event flow: capture, target, bubble

A click travels from document down to the target (capture), then back up (bubble). Listeners fire during bubbling unless you say otherwise:

document.addEventListener("click", () => console.log("document"));
button.addEventListener("click", () => console.log("button"));
// click the button: "button" then "document"
container.addEventListener("click", handler, { capture: true });

Controls you will use:

event.preventDefault();    // stop the default action: submit, navigation
event.stopPropagation();   // stop the event travelling further
event.target;              // the element that was clicked
event.currentTarget;       // the element the listener is attached to

stopPropagation breaks delegation and analytics listeners above you. Reach for it only when a component genuinely must not leak events, and never as a fix for “my handler runs twice”.

Scroll and touch listeners should be { passive: true } unless they call preventDefault(); otherwise the browser waits for your handler before scrolling.

Event delegation

For a list whose rows come and go, attach one listener to the container and identify the source:

list.addEventListener("click", (event) => {
  const btn = event.target.closest("button[data-action]");
  if (!btn || !list.contains(btn)) return;

  if (btn.dataset.action === "delete") {
    btn.closest("li")?.remove();
  }
});

Benefits: one listener regardless of row count, new rows work immediately, and there is nothing to unbind per row. The list.contains(btn) check keeps a nested list or an unrelated button[data-action] elsewhere in the document from matching.

Cleaning up listeners

In a single-page app a replaced component leaves listeners on detached nodes: memory that never gets freed, and handlers that react to a state that no longer exists.

const controller = new AbortController();

button.addEventListener("click", onClick, { signal: controller.signal });
window.addEventListener("resize", onResize, { signal: controller.signal });

// teardown
controller.abort();

One abort() removes every listener registered with that signal, which beats keeping a reference to each handler. removeEventListener requires the exact same function reference and the same capture flag, which is why anonymous arrow functions are impossible to remove.

Forms

<form id="login">
  <input name="email" type="email" required />
  <button type="submit">Sign in</button>
</form>
form.addEventListener("submit", async (event) => {
  event.preventDefault();                  // no page reload
  if (!form.reportValidity()) return;      // browser messages, your flow

  const payload = Object.fromEntries(new FormData(form));
  const button = form.querySelector("button[type=submit]");
  button.disabled = true;

  try {
    await fetch("/api/login", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(payload),
    });
  } finally {
    button.disabled = false;
  }
});

Use the built-in validation first — required, type="email", pattern, minlength, checkValidity(). Disabling the submit button prevents double submission, but disable it after checking validity, or the browser cannot show its messages.

FormData only includes controls with a name, and an unchecked checkbox contributes nothing — read input.checked when you need an explicit false.

High-frequency events

scroll, resize and input can fire dozens of times per second. Run the expensive part on a schedule instead:

function debounce(fn, ms = 200) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}

searchInput.addEventListener("input", debounce((e) => search(e.target.value), 250));

Debounce when you want the pause (input before a request), throttle when you want a steady rate (scroll progress), and batch visual work into requestAnimationFrame so it happens at most once per frame.

Common mistakes

1. Querying before the node exists

document.querySelector("#app") returns null and the next line throws. Load the script with type="module" or defer, or place it before </body>.

2. Building markup from user data

innerHTML with interpolated input is the first entry point for XSS. Text goes through textContent; attributes go through setAttribute with validated values.

3. Mixing up target and currentTarget

target changes as the event bubbles, currentTarget is where you attached the listener. Arrow functions have no own this, so currentTarget is also the clearest spelling.

4. Closures over a loop variable

for (var i = 0; i < 3; i++) {
  buttons[i].addEventListener("click", () => console.log(i)); // 3, 3, 3
}

for (let i = 0; i < 3; i++) {
  buttons[i].addEventListener("click", () => console.log(i)); // 0, 1, 2
}

5. removeEventListener that removes nothing

A different function reference, or a different capture flag, removes nothing and throws nothing. AbortController avoids the whole class of bug.

6. Overwriting style.cssText

It clears every inline style you set before. Use classList for state and individual properties for values.

7. Forgetting preventDefault on a form

The page reloads, state is lost, and the console shows nothing wrong. When “my JS does nothing”, check whether the URL changed.

Summary

  • Query with querySelector(All), insert with textContent and a DocumentFragment.
  • Events flow capture → target → bubble; target triggers, currentTarget listens.
  • Delegate for dynamic lists, and verify containment with closest plus contains.
  • Clean up with one AbortController per component.
  • Let the browser validate forms, then preventDefault, disable the button and handle errors.
  • Debounce high-frequency input and separate layout reads from writes.

Next: the tooling that turns these files into something installable and deployable.