072: 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, andcurrentTargetcorrectly; - 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
- Why should
render()useclassList.toggle(name, condition)? - Where should a task's completion value live: a CSS class or state?
- What is the safe way to place a task title into a new
span?
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: addEventListenerevent.target: Deepest element where the event originated. — Source: MDN: Event.targetevent.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. Code performs setup, then the browser waits. When something occurs, it dispatches an event and calls matching listeners.
button.addEventListener("click", handleClick);
This does not call handleClick now. It passes the function itself so the browser can call it later. This common mistake calls immediately:
// Wrong: passes handleClick's return value.
button.addEventListener("click", handleClick());
Events fit our architecture:
state -> render -> user event -> listener updates state -> render
An event reports what happened. The handler determines the state transition. render() determines the resulting DOM.
Self-Study Example: Accessible Counters
Use this complete page:
<!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:
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();
Test with a mouse, touch, Tab, Enter, and Space. Because these are native buttons, click events represent keyboard activation too. Do not add a keydown listener to imitate button activation.
Click the + symbol. The event's target may be the inner span; currentTarget is always controls while that listener runs. closest("button[data-step]") climbs from the target to the actionable button. The containment check ensures the result belongs to this control group, which matters if a selector could cross a component boundary.
This is event delegation: one listener handles both step buttons. A button added later also works because its click bubbles to the stable controls element.
Target Versus Current Target
For this markup:
<button id="save"><span>Save task</span></button>
and listener:
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 element with the listener is the one you need. Use target plus closest() for delegation. Do not assume target is an Element for every event in every context; delegated code can guard:
if (!(event.target instanceof Element)) {
return;
}
During a synchronous listener, currentTarget is defined. After the callback finishes, it becomes null; capture it first if asynchronous code genuinely needs the reference.
Listener Lifecycle
A listener remains registered until its target is discarded, it is removed with the same callback/capture setting, an associated AbortSignal is aborted, or { once: true } removes it after one call.
function announceReady() {
console.log("Ready once");
}
button.addEventListener("click", announceReady, { once: true });
For reusable UI setup/cleanup, an abort controller is convenient:
const controller = new AbortController();
controls.addEventListener("click", handleControlsClick, {
signal: controller.signal,
});
// Later:
controller.abort();
For a simple page that lasts until navigation, permanent setup listeners are normal. Do not add listeners inside render(); every render could register another callback and one click would update state repeatedly.
Intermediate Example: Todo Completion and Delete
Extend 060's task item so the checkbox and delete button carry actions:
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);
Use one click listener on the list:
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();
});
This demonstrates the complete cycle. 073 will improve checkbox handling by using its semantic change event and actual checked value rather than inverting state.
Optional Advanced Example: Listener Options
Common options are:
once: automatically remove after the first invocation;signal: remove when anAbortControlleraborts;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.
window.addEventListener("scroll", reportScroll, { passive: true });
Do not mechanically add passive to every listener. A passive listener cannot successfully call preventDefault(). Basic click and form examples do not need it.
Mistakes and Debugging
- Using inline handlers: avoid
onclick="...". It mixes behavior into HTML, depends on globals, and is harder to compose. UseaddEventListener(). - Calling instead of passing: pass
handleClick, nothandleClick(). - Using the global
event: receive the callback parameter. The legacy global is unreliable and not available in all contexts. - Assuming
targetis the button: nested content may be the target. UsecurrentTargetorclosest(). - 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, place a breakpoint in the handler, and log event.type, target, currentTarget, and state before/after. If one click increments twice, search for repeated registrations.
Accessibility, Security, and Performance
Accessibility: use native button, checkbox, link, and form controls. They supply keyboard interaction, focus behavior, names, roles, and states. Avoid click listeners on div or span; adding tabindex does not recreate full button semantics. Keep focus visible. Status updates should use role="status" or an appropriate polite live region without stealing focus, but avoid announcing noisy, low-value changes.
Security: events and data attributes are input, not authorization. Verify the action against an allowlist and find the referenced item in current state. Synthetic events can be dispatched by scripts; isTrusted is not a substitute for server authorization. Continue to render titles with textContent.
Performance: delegation reduces registrations for large dynamic lists and naturally covers newly rendered controls. It is not mandatory for two stable buttons. Keep handlers short; expensive synchronous work blocks input and rendering. Update state once and render once per logical action.
Exercises
Core
Add a +5 button using only markup. Confirm the delegated listener handles it without 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
<button type="button" data-step="5">Increase by five</button>
Place it inside #controls. Delegation reads data-step and updates state.
Practice
<button id="clamp" type="button" aria-pressed="false">Clamp to zero</button>
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
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.targetidentifies the dispatch target;currentTargetidentifies 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
- MDN:
EventTarget.addEventListener() - MDN:
Event.target - MDN:
Event.currentTarget - MDN: Event bubbling
- WHATWG DOM: Introduction to events
- WHATWG DOM:
EventTarget - WCAG 2.2 SC 2.1.1: Keyboard
- WCAG 2.2 SC 4.1.3: Status Messages
Delegation edge cases and event-rate control
Delegation depends on bubbling. focus and blur do not bubble (use focusin/focusout when delegating); a listener on an ancestor cannot catch an event stopped by an inner component; and event.target may be a text node or another non-Element target in some event types. Always guard before calling closest() and verify containment.
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);
});
Use debounce when only the final burst matters, such as a search request. Use throttle when updates should happen at most once per interval, such as scroll reporting.
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, and should define whether throttle has leading/trailing behavior. Do not debounce validation that must run on submit, and do not use a timer to replace native keyboard behavior.
Interview questions
- Why use
currentTargetrather thantargetfor a direct button listener?currentTargetremains the element whose listener is executing even when a nested icon was clicked. - Why can delegation fail for
focus?focusdoes not bubble; delegatefocusinor attach direct listeners. - Debounce or throttle for autocomplete? Debounce usually avoids requests until typing pauses; cancel or ignore stale requests as well.
- 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
An event is dispatched through capture, target, and bubble phases. event.target is where dispatch began; event.currentTarget is the element whose listener is 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 fix 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 for 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 button behavior.
