FullStack Course LogoFullStack Course
Module: JavaScript
JavaScript·072·9 MIN READ

072: Events I: Event Objects, Listeners, and Propagation

TOPICS COVERED: Events I: Event Objects, Listeners, and Propagation

Outcomes

By the end of this lesson, you can:

  • register listeners with addEventListener();
  • explain event-driven programming and listener lifecycle;
  • use the event object, target, and currentTarget correctly;
  • connect button clicks to state updates and re-rendering;
  • use event delegation with closest() safely; and
  • choose native interactive elements instead of recreating controls.

Retrieval Warm-Up

Before reading, retrieve the key ideas from the previous lesson:

  1. Why should render() use classList.toggle(name, condition)?
  2. Where should a task's completion value live: a CSS class or state?
  3. What is the safe way to place a task title into a new span?

These questions are deliberately small. They connect the state-and-render model to the event handlers you will write here.

Terms

  • Event: Object signaling an occurrence such as click, input, submit. — Source: WHATWG DOM: Events
  • Event target: Object on which the event was dispatched (event.target). — Source: WHATWG DOM: Event target
  • Listener: Function registered via addEventListener and invoked on matching events. — Source: MDN: addEventListener
  • addEventListener(): Registers an event-type handler on an EventTarget with optional options. — Source: MDN: addEventListener
  • event.target: Deepest element where the event originated. — Source: MDN: Event.target
  • event.currentTarget: Element whose listener is currently running during propagation. — Source: MDN: Event.currentTarget
  • Event delegation: Single ancestor listener handling descendant events via bubbling and closest(). — Source: MDN: Introduction to events
  • Bubbling: Phase where the event propagates upward from target through ancestors. — Source: WHATWG DOM: Dispatching events
  • Lifecycle: An event’s journey: dispatch → capture → target → bubble → completion. — Source: WHATWG DOM: Dispatching events
  • Event propagation (official): "Events propagate through capture phase, target phase, and bubble phase." — Source: WHATWG DOM: Dispatching events
  • Lifecycle (event): "The event lifecycle: creation, dispatching through propagation, handling, and cleanup." — Source: MDN: Event — Lifecycle

Mental Model: Subscribe, Wait, React

Browser JavaScript is event-driven. Your code performs setup and then yields control back to the browser. When an interaction or other occurrence happens, the browser dispatches an event and invokes the listeners that match it.

js
button.addEventListener("click", handleClick);

That line does not call handleClick during setup. It passes the function object to the browser, which can call it later when a click occurs. A common error is to call the function immediately:

js
// Wrong: passes handleClick's return value.
button.addEventListener("click", handleClick());

The useful distinction is between registering work and doing work. addEventListener() registers the response; the event later supplies the occasion for that response.

Events fit the state-and-render architecture you have already been using:

text
state -> render -> user event -> listener updates state -> render

The event tells you what happened. The handler decides which state transition that occurrence represents. Then render() turns the updated state into the corresponding DOM. Keeping those responsibilities distinct makes event code easier to reason about and debug.

Self-Study Example: Accessible Counters

Start with this complete page. The controls are real buttons, and the status output has a semantic role for assistive technology.

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Event counters</title>
    <script src="app.js" defer></script>
  </head>
  <body>
    <main>
      <h1>Practice counter</h1>
      <p id="count" role="status">Count: 0</p>
      <div id="controls">
        <button type="button" data-step="-1">Decrease</button>
        <button type="button" data-step="1">
          <span aria-hidden="true">+</span> Increase
        </button>
      </div>
      <button id="reset" type="button">Reset</button>
    </main>
  </body>
</html>

Add app.js:

js
const state = { count: 0 };

const countOutput = document.querySelector("#count");
const controls = document.querySelector("#controls");
const resetButton = document.querySelector("#reset");

function render() {
  countOutput.textContent = `Count: ${state.count}`;
  resetButton.disabled = state.count === 0;
}

function handleControlsClick(event) {
  console.log("target:", event.target);
  console.log("currentTarget:", event.currentTarget);

  const button = event.target.closest("button[data-step]");

  if (!button || !controls.contains(button)) {
    return;
  }

  const step = Number(button.dataset.step);

  if (!Number.isFinite(step)) {
    return;
  }

  state.count += step;
  render();
}

function resetCount() {
  state.count = 0;
  render();
}

controls.addEventListener("click", handleControlsClick);
resetButton.addEventListener("click", resetCount);

render();

Exercise the page with a mouse, touch, Tab, Enter, and Space. Native buttons produce click events for keyboard activation as well as pointer interaction, so there is no reason to add a keydown listener that imitates button behavior.

Click the + symbol rather than the surrounding button text. The event's target may then be the inner span, while currentTarget is controls for the entire time that the delegated listener is running. closest("button[data-step]") starts at the target and walks up the ancestor chain until it finds the actionable button. The containment check confirms that the match belongs to this control group. That check becomes important when selectors might otherwise cross a component boundary.

This is event delegation: one listener handles both step buttons. If another matching button is added later, it also works because its click bubbles to the stable controls element. You do not need to register a new listener for every button.

Target Versus Current Target

This small example makes the distinction concrete:

html
<button id="save"><span>Save task</span></button>

and listener:

js
const saveButton = document.querySelector("#save");

saveButton.addEventListener("click", (event) => {
  console.log(event.target);        // span if the span was clicked
  console.log(event.currentTarget); // button
});

Use currentTarget when the listener is attached directly to the element you need. Use target together with closest() when the listener is delegated from an ancestor. This is where people usually get confused: the element that caused the event and the element whose callback is currently executing are not necessarily the same.

Do not assume target is an Element for every event in every context. Delegated code can guard before calling element-only methods:

js
if (!(event.target instanceof Element)) {
  return;
}

During a synchronous listener, currentTarget identifies the listener's element. Once the callback has returned, the property becomes null; capture the reference first if asynchronous code genuinely needs it.

Listener Lifecycle

A listener stays registered until its target is discarded, it is removed using the same callback and capture setting, an associated AbortSignal is aborted, or { once: true } removes it after its first call. This lifecycle matters when a page creates and destroys UI repeatedly: registration without corresponding cleanup can leave duplicate work or retain references longer than intended.

js
function announceReady() {
  console.log("Ready once");
}

button.addEventListener("click", announceReady, { once: true });

For reusable UI setup and cleanup, an abort controller is often the simplest grouping mechanism:

js
const controller = new AbortController();

controls.addEventListener("click", handleControlsClick, {
  signal: controller.signal,
});

// Later:
controller.abort();

On a simple page that lasts until navigation, permanent setup listeners are normal. The rule that matters is where registration occurs: do not add listeners inside render(). Every render would register another callback, so one click could update state repeatedly.

Intermediate Example: Todo Completion and Delete

Extend 060's task item so the checkbox and delete button carry explicit actions:

js
checkbox.dataset.action = "toggle";

const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.dataset.action = "delete";
deleteButton.textContent = `Delete ${task.title}`;

item.append(checkbox, label, " ", deleteButton);

Then put one click listener on the list:

js
taskList.addEventListener("click", (event) => {
  if (!(event.target instanceof Element)) {
    return;
  }

  const control = event.target.closest("[data-action]");
  const item = event.target.closest("[data-task-id]");

  if (!control || !item || !taskList.contains(item)) {
    return;
  }

  const task = state.tasks.find(
    (item) => item.id === item.dataset.taskId,
  );

  if (!task) {
    return;
  }

  if (control.dataset.action === "toggle") {
    task.completed = !task.completed;
  } else if (control.dataset.action === "delete") {
    state.tasks = state.tasks.filter((item) => item.id !== task.id);
  } else {
    return;
  }

  render();
});

The full cycle is visible here: identify the relevant descendant, locate its current state record, perform an allowlisted state transition, and render the result. 073 will refine checkbox handling by using the checkbox's semantic change event and its actual checked value instead of blindly inverting state.

Optional Advanced Example: Listener Options

The options most often encountered are:

  • once: automatically remove after the first invocation;
  • signal: remove when an AbortController aborts;
  • capture: run during capture rather than ordinary target/bubble handling;
  • passive: promise not to cancel the event's default action, useful for relevant scrolling input.
js
window.addEventListener("scroll", reportScroll, { passive: true });

Do not add passive mechanically to every listener. A passive listener cannot successfully call preventDefault(). Basic click and form examples do not need this option.

Mistakes and Debugging

  • Using inline handlers: avoid onclick="...". It mixes behavior into HTML, depends on globals, and is harder to compose. Use addEventListener().
  • Calling instead of passing: pass handleClick, not handleClick().
  • Using the global event: receive the callback parameter. The legacy global is unreliable and is not available in all contexts.
  • Assuming target is the button: nested content may be the target. Use currentTarget or closest().
  • Adding listeners during render: repeated renders create duplicate work. Register stable listeners once.
  • Removing with a new anonymous function: function objects differ. Keep the original reference or use an abort signal.
  • Stopping propagation routinely: stopPropagation() can break other behavior. Delegate deliberately and return when an event is irrelevant.
  • Updating only displayed text: mutate state, then call render().

Use DevTools' event-listener inspection, set a breakpoint in the handler, and log event.type, target, currentTarget, and state before and after the transition. If one click increments twice, repeated listener registration is one of the first things to investigate.

Accessibility, Security, and Performance

Accessibility: prefer native button, checkbox, link, and form controls. They already provide keyboard interaction, focus behavior, accessible names, roles, and states. Avoid click listeners on div or span; adding tabindex does not recreate all of a button's semantics and behavior. Keep focus visible. Status updates can use role="status" or another appropriate polite live region without stealing focus, but avoid announcing noisy, low-value changes.

Security: events and data attributes are input, not authorization. Check the action against an allowlist and look up the referenced item in current state. Scripts can dispatch synthetic events, so isTrusted is not a replacement for server-side authorization. Continue to render titles with textContent rather than treating task data as HTML.

Performance: delegation reduces the number of registrations in large dynamic lists and naturally covers controls rendered later. It is not required for two stable buttons. Keep handlers short because expensive synchronous work blocks input processing and rendering. For one logical action, update state once and render once.

Exercises

Core

Add a +5 button using only markup. Confirm that the delegated listener handles it without any new JavaScript.

Practice

Add a Clamp to zero toggle button. When pressed, negative results become zero. Render its aria-pressed state.

Professional Extension

Refactor all counter listeners to use one AbortController, then add a Disable controls button that aborts them and disables the buttons.

Core

html
<button type="button" data-step="5">Increase by five</button>

Place it inside #controls. Delegation reads data-step and updates state.

Practice

html
<button id="clamp" type="button" aria-pressed="false">Clamp to zero</button>
js
state.clamp = false;
const clampButton = document.querySelector("#clamp");

clampButton.addEventListener("click", () => {
  state.clamp = !state.clamp;
  render();
});

// After adding step in handleControlsClick:
if (state.clamp && state.count < 0) {
  state.count = 0;
}

// Inside render():
clampButton.setAttribute("aria-pressed", String(state.clamp));

Professional Extension

js
const controller = new AbortController();
const options = { signal: controller.signal };
const disableButton = document.querySelector("#disable");

controls.addEventListener("click", handleControlsClick, options);
resetButton.addEventListener("click", resetCount, options);

disableButton.addEventListener("click", () => {
  controller.abort();
  for (const button of document.querySelectorAll("button")) {
    button.disabled = true;
  }
});

Add <button id="disable" type="button">Disable controls</button> and remove the original listener registrations.

Recap

  • addEventListener() subscribes a callback; it does not run it immediately.
  • target identifies the dispatch target; currentTarget identifies the current listener target.
  • Bubbling enables delegation from a stable ancestor.
  • Native controls provide interaction across input methods.
  • Register setup listeners once, update state in handlers, and render once.
  • Use once, signal, capture, and passive options only with a reason.

Official References

Delegation edge cases and event-rate control

Delegation relies on bubbling, so there are boundaries to keep in mind. focus and blur do not bubble; use focusin and focusout when delegating those interactions. An ancestor also cannot catch an event that an inner component has stopped. Finally, event.target may be a text node or another non-Element target for some event types. Guard before calling closest() and verify containment before acting on the match.

js
list.addEventListener("click", (event) => {
  if (!(event.target instanceof Element)) return;
  const button = event.target.closest("button[data-id]");
  if (!button || !list.contains(button)) return;
  console.log("selected", button.dataset.id);
});

Event rate is a separate design concern. Use debounce when only the final call in a burst matters, such as a search request. Use throttle when updates should happen at most once per interval, such as scroll reporting.

js
function debounce(callback, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => callback(...args), delay);
  };
}

function throttle(callback, interval) {
  let last = -Infinity;
  return (...args) => {
    const now = performance.now();
    if (now - last < interval) return;
    last = now;
    callback(...args);
  };
}

let searches = 0;
const search = debounce(() => searches += 1, 20);
search(); search(); search();
setTimeout(() => console.assert(searches === 1), 30);

Production versions should expose cancel() so teardown can clear pending timers. They should also define whether throttling has leading behavior, trailing behavior, or both. Do not debounce validation that must run on submit, and do not use a timer as a substitute for native keyboard behavior.

Interview questions

  1. Why use currentTarget rather than target for a direct button listener? currentTarget remains the element whose listener is executing even when a nested icon was clicked.
  2. Why can delegation fail for focus? focus does not bubble; delegate focusin or attach direct listeners.
  3. Debounce or throttle for autocomplete? Debounce usually avoids requests until typing pauses; cancel or ignore stale requests as well.
  4. What should tests cover? Nested click targets, dynamically inserted buttons, clicks outside the list, non-bubbling events, repeated setup, teardown, and timer cancellation.

The browser event system

The browser dispatches an event through capture, target, and bubble phases. event.target is where dispatch began; event.currentTarget is the element whose listener is currently running. They are equal only when the listener is attached to the dispatch target.

Use preventDefault() to stop a browser default action. Use stopPropagation() only when crossing a boundary would be incorrect; it is not a general solution for duplicate handlers. Delegation is appropriate for a dynamic list, but guard closest() with an Element check and a containment check. Remove listeners with the same function reference, or use AbortController to manage a group of listeners.

Test mouse, keyboard, touch, nested controls, dynamically inserted controls, repeated initialization, and teardown. A native button already supplies keyboard activation; a clickable div with tabindex does not reproduce all of that button behavior.

Reader page: /javascript/lesson/072/events-i-event-objects-listeners-and-propagation