← Front-End Developer — all chapters  ·  Kelick Tech Hub

Chapter 20 — Events and Event Handling

Section 1 — What an event is

An event is something that happens on a page: a click, a key press, a form being submitted, a page finishing loading, or a mouse moving over an element. Events are the browser's way of telling your code that something happened, so your code can respond. Without events, a page would be static after loading: scripts could change it once, but could not react to a user, because they would have no way of knowing the user did anything. With events, a page becomes interactive, because every user action, and many browser actions, generate an event, and your code can listen for the events it cares about and run in response. That listen-and-respond pattern is the core of interactivity, and the subject of this chapter.

Events have a source, a type, and a target. The source is what caused the event, usually a user action or a browser process. The type is the event's name, such as click, keydown, or submit, which tells listeners what kind of thing happened. The target is the element the event happened on, such as the button that was clicked, and it is available through the event object, covered in Section 4. Those three pieces are enough to describe most events, and they are the vocabulary you will use with other developers: "a click event on the submit button", for example, names the type and target in one phrase.

Think of events as announcements, not commands. When a user clicks a button, the browser announces "a click happened on this button", and any code listening for clicks on that button hears it and runs. Code that is not listening hears nothing and does nothing, which is why the same click can trigger one feature, several features, or no feature at all, depending on which listeners are attached. Listening is therefore a separate step from handling: you must first attach a listener, saying "I care about this event on this element", and only then will your handler run. Forgetting to attach a listener is a common reason a button "does nothing": the handler exists, but no one told the browser to call it for that event.

Events are also asynchronous, in that you do not know when they will happen. Your script does not wait for a click, blocking everything else; instead, it sets up listeners and finishes, and the browser calls a handler later, whenever the event occurs, even minutes later. Handlers must therefore be self-contained: they cannot rely on variables that existed only during setup, unless those variables are kept alive in scope, and they must read the current state of the page at the moment they run, not the state as it was when the listener was attached. Practise by thinking of a button on a page you use daily, and naming its event, its target, and what should happen when it fires. That exercise turns the abstract idea of an event into a concrete design for a feature.

// An event is announced by the browser; a listener hears it:
const button = document.querySelector("button");
button.addEventListener("click", function () {
  console.log("A click event happened on the button");
});

Check: In one sentence, what is an event, and what does listening for it allow your code to do?

Section 2 — Listening with addEventListener

addEventListener() is the standard way to listen for an event. You call it on an element, with the event type as a string and a handler function, as in button.addEventListener("click", handleClick). When the event occurs on that element, the browser calls your handler, passing it an event object with details about what happened. The event type is written without the "on" prefix: the event is click, not onclick, and submit, not onsubmit. Mixing the two forms — addEventListener("onclick", ...) — silently listens for an event type that never occurs, so the handler never runs, with no error to explain why. Checking the event type spelling, without "on", is the first diagnostic when a listener seems dead.

The handler can be a named function, an anonymous function written inline, or an arrow function, each with trade-offs covered later in this chapter. A named function is reusable and removable, because you can refer to it by name in both addEventListener() and removeEventListener(), and it appears by name in error messages, which helps debugging. An inline anonymous function is convenient for a short, one-off handler, but it cannot be removed later, because there is no name to refer to it by. An arrow function is concise, but it does not get its own this, which matters when a handler relies on this for the element, as Section 37 explains. For learning, named functions are the safest default, because they are clear, debuggable, and removable.

addEventListener() can be called many times on the same element, for the same event type, and each listener will run when the event occurs, in the order added. That is a major advantage over the older property approach, covered in the next section, because it allows different parts of a program to respond to the same event independently, without overwriting each other. A button click might trigger a data update in one listener, a display update in another, and an analytics log in a third, and all three will run, in order, from a single click. That separation keeps code modular, because each listener can be added, removed, or changed without affecting the others, as long as they do not depend on each other's side effects in a fragile order.

Attaching a listener does not run the handler immediately; it only registers it for future events. That distinction is the source of the common mistake covered in Section 38, where a handler is called with parentheses at registration time, running it once immediately and registering its return value, usually undefined, as the listener, so nothing runs on the actual event. The correct form passes the function itself, without parentheses, as in addEventListener("click", handleClick), not addEventListener("click", handleClick()). The parentheses belong to the browser, which will add them later, when the event occurs. Practise by attaching a click listener that logs a message, clicking several times, and confirming it logs once per click, not once at setup.

function handleClick() {
  console.log("Button clicked");
}
const button = document.querySelector("button");
button.addEventListener("click", handleClick);
button.addEventListener("click", function () {
  console.log("Second listener also runs");
});

Check: What two arguments does addEventListener() need, and why is the event type written without "on"?

Section 3 — The onclick property and why addEventListener wins

Before addEventListener() became standard, the common way to handle an event was to assign a function to an element's on property, such as button.onclick = handleClick. That property approach still works, and you will see it in older code, so you should recognise it, but it has a fundamental limitation: an element has only one onclick property, so assigning a second handler overwrites the first, instead of adding to it. If two parts of a program both try to handle clicks on the same button this way, the second assignment silently replaces the first, and the first feature stops working, with no error, which is a difficult bug to trace, because the broken feature's code looks correct in isolation. That overwriting is the main reason modern code prefers addEventListener(), which allows many listeners for the same event.

There is also an HTML-attribute version, where the handler is written inline in the markup, as in <button onclick="handleClick()">Click me</button>. That mixes behaviour into HTML, which the separation-of-concerns principle in earlier chapters argues against, because it makes the HTML harder to read and maintain, and it requires the handler to be globally available, which pollutes the global scope. It also makes it difficult to attach the same handler conditionally, or to remove it later, because it is declared in markup rather than in code that can be controlled programmatically. Inline handlers are therefore discouraged in modern front-end work, except for very small, throwaway examples, and this book uses addEventListener() throughout, so behaviour lives in JavaScript, structure in HTML, and styling in CSS.

The property approach does have one small advantage: assigning null to the property removes the handler, as in button.onclick = null, which is simpler than removeEventListener(), because it does not require keeping a reference to the original function. That simplicity is not enough to outweigh the single-handler limitation, and removeEventListener(), covered in Section 21, provides the same removal for addEventListener() listeners, provided you keep the function reference, which named functions make easy. In practice, the choice is clear: use addEventListener() for all new code, recognise the property and inline forms in older code, and convert them when you modify older code, so multiple features can safely share the same event.

A useful way to remember the distinction is to think of the property as a single slot and addEventListener() as a list. A slot can hold one thing, and putting a new thing in pushes the old one out. A list can hold many things, and adding a new thing appends it without disturbing the existing ones. When a feature breaks after another feature is added, and both handle the same event on the same element, check whether either used the property form, because that is a likely cause, and converting both to addEventListener() will usually fix it. That diagnostic — "did a later assignment overwrite an earlier handler?" — belongs on your event-debugging checklist.

const button = document.querySelector("button");
// Old property form: only one handler survives
button.onclick = function () { console.log("First"); };
button.onclick = function () { console.log("Second"); }; // replaces First
// Modern form: both handlers survive
button.addEventListener("click", function () { console.log("First"); });
button.addEventListener("click", function () { console.log("Second"); });

Check: Why does assigning a second onclick handler remove the first, while addEventListener() does not?

Section 4 — The event object

When an event occurs, the browser calls your handler with an event object as its first argument, conventionally named event or e, which carries details about what happened. The handler declares that parameter, as in function handleClick(event) { ... }, and inside it, event gives access to the target element, the event type, the mouse position or key pressed, and methods for controlling the event's default behaviour and propagation, covered in later sections. Without the event object, a handler would know only that something happened, not what, where, or how, which would make most handlers useless: a click handler needs to know which element was clicked, a key handler needs to know which key was pressed, and a form handler needs to know which form was submitted.

The event object has properties common to all events, and properties specific to certain event types. Common properties include type, the event's name, such as "click"; target, the element the event happened on; currentTarget, the element the listener is attached to, which may differ from the target when events bubble, as Section 12 explains; and timeStamp, when the event occurred. Mouse events add clientX and clientY for position, and button for which mouse button was pressed. Keyboard events add key, the character or key name, and code, the physical key, covered in Section 7. Form events add access to the form through the target, from which values can be read. Start with target and type for every event, then look for the type-specific properties you need, in documentation or by logging the object and inspecting it.

Logging the event object is a valuable debugging habit. When a handler behaves unexpectedly, logging event shows exactly what the browser passed: the target, the type, the key or mouse details, and any other properties that might explain the behaviour. A related habit is to log event.target specifically, when you need to know which element was involved, because that is the most frequently needed detail, and the one most often confused with currentTarget.

The event object is created fresh for each occurrence, so it describes one specific click, one specific key press, or one specific submission, not the event type in general. Storing an event object for later use is therefore usually a mistake, because its properties describe a moment that has passed, and some, such as the target's state, may have changed by the time you use it. The correct pattern is to read the needed details from the event object inside the handler, at the moment the event occurs, and to store those details — a value, an id, a key name — in your own variables if you need them later, rather than storing the event object itself. That keeps handlers simple and avoids stale-event bugs, where code acts on an old event as if it were current.

function handleClick(event) {
  console.log(event.type); // "click"
  console.log(event.target); // the clicked element
  console.log(event.clientX, event.clientY); // mouse position
}
document.querySelector("button").addEventListener("click", handleClick);

Check: What does the event object give a handler that the event type alone does not?

Section 5 — Click: the most common event

The click event fires when a user activates an element, usually by pressing and releasing the primary mouse button over it, or by pressing Enter or Space while it is focused, for buttons and links. A click handler on a non-interactive element, such as a div, will work for mouse clicks but not for keyboard activation, unless you add keyboard handling and focusability yourself, which is why interactive elements should be real buttons or links, not styled divs with click handlers, a point Section 43 returns to. Using native interactive elements gives you keyboard click behaviour for free.

A click is not a single low-level action; it is a sequence. Pressing the mouse button fires mousedown, releasing it fires mouseup, and if both happen over the same element, a click fires after them. Listening for mousedown instead of click for a button would trigger on press, before the user has a chance to change their mind by moving the mouse away before releasing, which is not standard button behaviour, and it would also miss keyboard activation entirely, because keyboard activation has no mouse press. So: use click for activation, and lower-level mouse events only when you genuinely need press, release, or movement details.

Click handlers are where the data-render loop from the previous chapter becomes interactive. A typical click handler does three things: it reads something from the event or the page, such as which button was clicked or what value is in an input; it updates the data, such as pushing a new item to an array or toggling a flag; and it calls a render function to update the display from the new data. That three-step pattern — read, update, render — is the core of most click features, from adding a task to toggling a favourite to showing a panel. When a click feature fails, check those three steps in order: did it read the right input, did it update the data correctly, and did it re-render afterwards? A missing re-render is a common cause of a click that "updates the data but shows nothing", because the data changed but the display was never told to follow.

Clicks on links and submit buttons have default behaviours — navigating and submitting — that may need to be prevented, as Section 16 explains, when the click is meant to trigger JavaScript instead. For ordinary buttons not inside a form, there is no default behaviour to prevent, and the handler can simply do its job. For buttons inside a form, the default type is submit, which means a click will submit the form unless the button's type is set to button, a detail that catches beginners whose "add item" button inside a form unexpectedly reloads the page. Setting type="button" on buttons that should not submit, or handling the form's submit event instead of the button's click, are the two clean solutions, and both are better than preventing the default on every click without understanding why the submission happened.

const button = document.querySelector("#add");
const list = [];
button.addEventListener("click", function () {
  list.push("New item");
  console.log(list);
  // In a real page, call renderList(list) here
});

Check: Why is click, rather than mousedown, the right event for an ordinary button?

Section 6 — Mouse events beyond click

Beyond click, the mouse generates several events that describe its movement and buttons. mousedown fires when a button is pressed, mouseup when it is released, mousemove when the mouse moves over an element, mouseover when it enters an element, mouseout when it leaves, and mouseenter and mouseleave are similar to the last two but do not bubble, a difference covered in Section 13. Each event has a job: press and release for drag starts and ends, movement for tracking position, and enter and leave for hover effects, though hover effects themselves belong in CSS, as Chapter 19 noted. Knowing the full set lets you pick the precise event for a feature, rather than forcing click to do a job it was not designed for.

The difference between mouseover and mouseenter is worth learning early, because it causes confusing bugs in menus and tooltips. mouseover bubbles, so moving from a parent into a child fires a new mouseover on the child, and a mouseout on the parent, even though the mouse never left the parent's area. mouseenter does not bubble, so it fires only when the mouse genuinely enters the element, and mouseleave only when it genuinely leaves, regardless of movement between children. For a menu that should stay open while the mouse moves among its items, mouseenter and mouseleave on the menu container are therefore the correct pair, while mouseover and mouseout would flicker as the mouse crosses item boundaries. That flicker is a classic symptom of choosing the bubbling pair when the non-bubbling pair was needed.

Mouse events carry position information in the event object, which is essential for features like tooltips that follow the cursor, drag-and-drop, and drawing on a canvas. clientX and clientY give the position relative to the visible viewport, pageX and pageY give it relative to the whole document including scrolled area, and offsetX and offsetY give it relative to the target element. Choosing the right coordinate system depends on what you will do with the position: viewport coordinates suit fixed-position tooltips, page coordinates suit document-level placement, and offset coordinates suit positioning inside the target itself. Logging all three for a click on a test element, then scrolling the page and clicking again, shows how viewport and page coordinates diverge with scroll, while offset coordinates stay tied to the element.

A caution about mousemove: it fires very frequently, potentially dozens of times per second as the mouse moves, so a handler that does heavy work on every mousemove can make a page sluggish. For movement tracking, keep the handler light, or throttle it, as Section 30 explains, so it runs at most once per animation frame or once per short interval. Similarly, avoid attaching mousemove listeners to the whole document unless you genuinely need global tracking, such as during a drag, and remove the listener when the drag ends, so the page is not paying for movement tracking when nothing is being dragged. That attach-for-the-duration pattern — add on drag start, remove on drag end — is a good example of listener lifecycle management, covered in Sections 21 and 48.

const box = document.querySelector(".box");
box.addEventListener("mouseenter", function () {
  box.classList.add("hovered");
});
box.addEventListener("mouseleave", function () {
  box.classList.remove("hovered");
});
box.addEventListener("mousemove", function (event) {
  console.log(event.clientX, event.clientY);
});

Check: What is the difference between mouseover and mouseenter?

Section 7 — Keyboard events

Keyboard events fire when a user presses, holds, or releases a key. The main types are keydown, which fires when a key is first pressed and then repeatedly while it is held down, and keyup, which fires when it is released. The older keypress is now deprecated and should not be used in new code. For most features, keydown is the right event, because it fires promptly on press and gives you the key information in time to act, such as moving a character in a game or triggering a shortcut. keyup is useful when you need to know a key was released, for example to stop a movement that started on keydown. Using both together lets you track which keys are currently held down, by adding on keydown and removing on keyup, a technique used in games and custom keyboard navigation.

The event object for a keyboard event carries two related but different properties: key and code. key tells you what character or key name resulted from the press, such as "a", "A" (with Shift), "Enter", "ArrowLeft", or " " for Space, and it depends on the user's keyboard layout and modifiers. code tells you which physical key was pressed, such as "KeyA" for the A key position, regardless of layout, so it stays the same even on a keyboard where that physical key produces a different character. For typing and for shortcuts that should follow the character, such as pressing "s" to save, key is usually correct, because the user thinks in characters. For games and shortcuts tied to a physical position, such as WASD movement, code is often better, because position matters more than character. Confusing the two leads to shortcuts that work on one layout and fail on another, so choose deliberately based on whether your feature cares about character or position.

Modifier keys — Shift, Control, Alt, and Meta (Command on Mac, Windows key on Windows) — are reported as booleans: shiftKey, ctrlKey, altKey, and metaKey. A shortcut handler checks those booleans along with key, as in "Control plus S", and Section 24 covers shortcuts in detail. The safe pattern is to check both key and modifiers in the same keydown handler, at the moment the main key is pressed, when the modifier state is current and reliable.

Keyboard events only fire when an element that can receive keyboard input is focused, or when the listener is on document or window, which receives keyboard events regardless of focus, as long as the page itself has focus. Attaching a keydown listener to a div that is never focused will therefore do nothing, which is a common reason a keyboard feature "does not work": the listener is on the wrong element. For global shortcuts, attach to document. For input-specific behaviour, such as validating as the user types, attach to the input itself, which will be focused while typing. If it does not, check the listener's target before suspecting the key logic.

document.addEventListener("keydown", function (event) {
  console.log(event.key, event.code);
  if (event.key === "Enter") {
    console.log("Enter pressed");
  }
  if (event.ctrlKey && event.key === "s") {
    event.preventDefault();
    console.log("Save shortcut");
  }
});

Check: What is the difference between event.key and event.code?

Section 8 — Focus and blur events

Focus events fire when an element gains or loses keyboard focus. focus fires when an element becomes focused, blur when it stops being focused, and focusin and focusout are bubbling versions of the same events, which is important because focus and blur themselves do not bubble, as Section 13 explains. For form-wide focus handling, such as highlighting the current field's container or showing help text for the focused field, focusin and focusout on the form are therefore the practical choice, while focus and blur on individual inputs suit field-specific handling, such as validating one field when it loses focus.

Focus handling is used for several common features. Highlighting the active field, by adding a class to its container on focus and removing it on blur, helps users see where they are in a long form, though CSS :focus and :focus-within can often achieve the same effect without JavaScript, and should be preferred when they can. Showing contextual help on focus, such as a hint for a password field's requirements, is another natural focus feature, because the help appears exactly when the user needs it, and disappears on blur.

A focus-related detail is the order in which blur and focus fire when a user moves from one field to another. The outgoing field's blur fires before the incoming field's focus, which means a blur handler that tries to move focus elsewhere can interfere with the user's intended move, causing a focus fight where the browser and the script disagree about where focus should go. Avoid moving focus inside a blur handler unless you have a strong reason, such as a validation failure that must keep the user on the field, and even then, consider whether an error message without a forced focus move would be less disruptive. Forced focus moves are one of the most annoying behaviours on the web when overused, because they trap users in a field they are trying to leave, so they should be reserved for cases where leaving would cause data loss or a serious error, not for minor validation preferences.

Focus events also interact with the focus() method from Chapter 19. Calling focus() on an element triggers its focus event, just as a user tabbing to it would, so a focus handler will run for programmatic focus moves as well as for user moves, which is usually desired, because the handler's job — highlighting, showing help — applies in both cases. Guard against that by checking whether the element is already focused before calling focus(), or by ensuring the handler's focus move is conditional on a state that changes, so it cannot repeat forever. As with other events, the handler should respond to the event, not create a new event of the same type unconditionally.

const input = document.querySelector("#email");
input.addEventListener("focus", function () {
  console.log("Email field focused");
});
input.addEventListener("blur", function () {
  console.log("Email field left, value:", input.value);
});
document.querySelector("form").addEventListener("focusin", function (event) {
  console.log("Focused field:", event.target.id);
});

Check: Why is focusin more useful than focus for handling focus changes on a whole form?

Section 9 — Input and change events

The input event fires every time a control's value changes through user interaction, including every keystroke in a text field, every paste, and every selection change in a dropdown. The change event fires later, when the change is committed: for a text field, when it loses focus after being edited; for a checkbox, radio, or select, immediately when the selection changes, because there is no separate commit step. Use input for live feedback that should update as the user types, such as a character count, a live preview, or instant validation that does not punish incomplete typing. Use change for actions that should happen once, after the user has finished, such as saving a setting, recalculating a total, or validating a field on completion.

A common mistake is to use change for live search, where results should update as the user types, and then to wonder why results only appear after the field loses focus. The fix is to switch to input, which fires on every keystroke, though for search you should also debounce, as Section 29 explains, so a search does not run on every single keystroke. Conversely, using input for an expensive action, such as saving to a server on every keystroke, can cause many unnecessary requests, where change or a debounced input would be more appropriate. The choice is therefore about frequency and cost: cheap, live updates suit input; expensive, committed updates suit change or debounced input, and the wrong choice shows up as either laggy feedback or excessive work, both of which users notice.

Both events give you the control through event.target, from which you read the current value with value or checked, as Chapter 19 explained. The handler should read the value at the moment the event fires, because that value is the current state, and it should not rely on a value stored from an earlier event, which may be stale. For text inputs, trimming whitespace before using the value, and handling empty input gracefully, are small habits that prevent bugs where a search for " " returns nonsense or a count includes spaces the user did not intend.

Programmatic value changes do not fire input or change, as Chapter 19 noted, which means a script that sets an input's value will not trigger the handlers that a user typing would trigger. The fix, when you need handlers to run after a programmatic set, is to dispatch the event manually with dispatchEvent(), covered in Section 31, or to call the handler function directly with the relevant data. Knowing that programmatic sets are silent helps you decide whether to dispatch an event or to restructure the code so both the script and the handler call a shared update function, which is often cleaner, because it avoids faking an event for a non-user action.

const search = document.querySelector("#search");
search.addEventListener("input", function (event) {
  console.log("Live value:", event.target.value);
});
search.addEventListener("change", function (event) {
  console.log("Committed value:", event.target.value);
});
const select = document.querySelector("select");
select.addEventListener("change", function (event) {
  console.log("Selected:", event.target.value);
});

Check: When should you use input instead of change for a text field?

Section 10 — Submit events

The submit event fires on a form when it is submitted, whether by clicking a submit button, by pressing Enter in a text field inside the form, or by calling the form's requestSubmit() method. Listening for submit on the form, rather than for click on its button, is the correct approach, because it covers all submission paths, including keyboard submission, which a button-click listener would miss. A form that only handles button clicks will fail for users who submit with Enter, which is a common and expected way to submit, especially in short forms like search or login, and it will also fail for assistive-technology users who submit through other means. The form-level submit listener is therefore both more complete and more accessible than a button-level click listener.

The default behaviour of a submit event is to send the form's data to the URL in its action attribute, using the method in its method attribute, and then to load the response as a new page. For a JavaScript-handled form, where you want to validate the data, display errors without reloading, or send the data via an API and stay on the same page, that default must be prevented with event.preventDefault(), as Section 16 explains. The pattern is: listen for submit on the form, call preventDefault() first, then read the values, validate them, and either show errors or proceed, such as sending the data or updating the display. Calling preventDefault() first is important, because if validation throws an error before it is called, the default submission will still happen, reloading the page and losing the user's input and error state.

Reading a form's values in a submit handler can be done field by field, with querySelector() for each input, or with the FormData API, which builds a collection of all the form's named fields in one step, as in new FormData(form). FormData respects the form's structure, including unchecked checkboxes being omitted, and multi-selects contributing multiple values, which mirrors how a traditional submission would encode the data, and it is convenient for collecting a whole form without listing every field. For validation, however, field-by-field reading is often clearer, because validation messages need to refer to specific fields, and because FormData values are all strings, while some fields may need numeric conversion or special handling. Either approach is valid, and the choice depends on whether the handler needs the whole form as a bundle or needs to work field by field for validation and error display.

After a successful JavaScript submission, the form should give feedback and, usually, reset or update its display. Showing a success message, in a live region for accessibility, and moving focus to it or to the next relevant element, completes the submission experience. A submission handler that silently does nothing on success, leaving the user wondering whether it worked, is incomplete, even if the data was sent correctly, because the user's experience is defined by feedback, not by the network request alone.

const form = document.querySelector("form");
form.addEventListener("submit", function (event) {
  event.preventDefault();
  const data = new FormData(form);
  console.log("Name:", data.get("name"));
  const nameInput = form.querySelector("#name");
  if (!nameInput.value.trim()) {
    console.log("Name is required");
    return;
  }
  console.log("Form is valid, proceeding");
});

Check: Why should you listen for submit on the form instead of click on its button?

Section 11 — Window events: load, resize, scroll

Window events fire on the browser window rather than on a page element, and the three most common are load, resize, and scroll. load fires on window when the whole page, including images, stylesheets, and other resources, has finished loading, which is later than DOMContentLoaded, covered in Chapter 19, which fires when the HTML alone is parsed. For DOM manipulation, DOMContentLoaded is usually early enough, and waiting for load would delay your code unnecessarily, but for work that depends on images being loaded, such as measuring an image's size or drawing it to a canvas, load is the correct event, because those measurements are only reliable once the resource has arrived. resize fires when the window is resized, and scroll fires when the page or an element is scrolled, and both fire frequently during their respective actions, which brings performance considerations covered in Section 30.

resize and scroll handlers must be light, because they can fire many times per second while a user drags a window edge or scrolls a long page. A handler that does layout work, reads computed styles, or rebuilds a list on every resize or scroll event can make the interaction feel sluggish, because the browser is doing that work repeatedly for intermediate sizes or positions that the user never stops at. The standard remedies are throttling, which limits the handler to at most once per interval, and debouncing, which waits until the action pauses, both covered in Sections 29 and 30, and the choice depends on whether you need updates during the action, such as a live layout adjustment, or only after it, such as saving a final size. For many resize and scroll features, CSS alone is sufficient, as Chapter 19 noted for responsive layout, and a JavaScript handler should only be added when the response genuinely depends on data or on a measurement that CSS cannot provide.

Scroll handling has a specific use worth naming: infinite scrolling, where new content loads as the user nears the bottom of a page, and sticky elements or progress indicators that change as the user scrolls. Those features read the scroll position from window.scrollY and the document height, and they decide whether to load more or to update an indicator, but they should do so efficiently, by checking a threshold rather than rebuilding on every scroll event, and by using the Intersection Observer API for visibility detection where possible, which is more efficient than scroll handlers because the browser notifies your code only when an element enters or leaves view, rather than on every scroll step. Intersection Observer is beyond this chapter's scope in detail, but knowing it exists as the modern, efficient alternative to scroll-position polling is useful, because it is the tool professional code uses for lazy loading images and for infinite scroll, precisely to avoid the performance cost of frequent scroll handlers. For learning, a throttled scroll handler that logs the scroll position is a good first experiment, and it teaches the frequency problem directly, by showing how often the handler actually runs.

window.addEventListener("load", function () {
  console.log("Page and resources fully loaded");
});
window.addEventListener("resize", function () {
  console.log("Window width:", window.innerWidth);
});
window.addEventListener("scroll", function () {
  console.log("Scroll position:", window.scrollY);
});

Check: What is the difference between DOMContentLoaded and load, and when would you need load?

Section 12 — target versus currentTarget

event.target is the element the event happened on, while event.currentTarget is the element the listener is attached to. They are the same only when the event happens directly on the listening element. When events bubble, as the next section explains, a listener on a parent can hear an event that happened on a child, and then target is the child where the event originated, while currentTarget is the parent where the listener lives. That distinction is the key to event delegation, covered in Section 17, because delegation relies on a parent listener using target to discover which child was actually involved. Confusing the two is a common bug: code using currentTarget when it meant the clicked child will act on the container every time, while code using target when it meant the container will act on whichever child was clicked, even a nested span inside a button.

Suppose a list has a listener on the ul, and the user clicks a li inside it. The event's target is the li, or possibly an even deeper element inside it, such as a span holding its text, because the target is the deepest element the event actually hit. The event's currentTarget is the ul, because that is where the listener is attached, and it stays the ul throughout the bubbling journey. Inside the handler, this also refers to currentTarget, for a traditional function, though arrow functions do not get their own this, as Section 37 explains, so currentTarget is the reliable way to refer to the listening element in any function form. Logging both properties in a delegated handler shows the pair in action: target varies with the clicked child, currentTarget stays constant at the container, and that variation is exactly the information delegation uses.

The practical rule is: use target when you need to know what was actually interacted with, and use currentTarget when you need to know where the listener lives, or need a stable reference to the container. For direct listeners, where there is no bubbling involved, the two are identical, so the distinction does not matter, though currentTarget is slightly more explicit about intent. For delegated listeners, the distinction is essential, and the handler should usually start by checking target — often with closest() to find the relevant ancestor of the deepest target, as Section 17 shows — before acting, because the deepest target might be a child of the intended item rather than the item itself. That closest() step, from a raw target to a meaningful item, is a pattern you will use in almost every delegated handler.

const list = document.querySelector("ul");
list.addEventListener("click", function (event) {
  console.log("Target:", event.target);
  console.log("Current target:", event.currentTarget);
  const item = event.target.closest("li");
  if (item) {
    console.log("Clicked item:", item.textContent);
  }
});

Check: In a delegated listener on a list, what do target and currentTarget each refer to when an item is clicked?

Section 13 — Bubbling

Bubbling is the process by which an event, after firing on its target, travels up the DOM tree through its ancestors, firing listeners on each one in turn, until it reaches the document and then the window. A click on a button inside a card inside a section will first trigger the button's own click listeners, then the card's, then the section's, then the body's, and so on upward, in that order — like a bubble rising through water, the event rises through the tree. That upward journey is the default for most events, including click, input, and change, though some events, like focus and blur, do not bubble, and instead have bubbling counterparts, focusin and focusout, as Section 8 noted. Bubbling is what makes event delegation possible, because a parent can hear events from its children without a listener on each child, and it is also what can cause surprising double-handling, when both a child and a parent have listeners for the same event and both run for a single action, which may or may not be intended.

A listener on an ancestor does not know, from its own position alone, whether the event originated on itself or on a descendant, which is why it must check event.target, as the previous section explained, to decide whether to act. That check is the responsibility of delegated handlers: they must verify that the target, or its closest() ancestor, is an item they care about, and ignore the event otherwise, because otherwise a click on any part of the container, including empty space or an unrelated child, would trigger the handler. That filtering step is not optional for delegation; it is what turns a broad, bubbling listener into a precise feature.

Bubbling also means stopping an event matters, when a child and a parent should not both respond to the same action. A click on a button inside a card that both opens the card and triggers a button action, for example, might need the button's handler to stop the event from bubbling to the card, so the card does not also open, which is done with event.stopPropagation(), covered in Section 15. Without that stop, both handlers run, and the user experiences two actions from one click, which may be confusing or incorrect. Conversely, there are cases where both handlers should run, such as a global click tracker on the document that logs all clicks, plus specific handlers on individual buttons, where bubbling is exactly what allows the global tracker to work without a listener on every button. The decision to stop or allow bubbling is therefore a design decision about whether an action should be handled at one level only, or at several levels, and it should be made deliberately for each nested interaction.

document.querySelector("button").addEventListener("click", function () {
  console.log("Button handler");
});
document.querySelector(".card").addEventListener("click", function () {
  console.log("Card handler — runs second, via bubbling");
});
document.querySelector("section").addEventListener("click", function () {
  console.log("Section handler — runs third, via bubbling");
});

Check: In what order do listeners run when a click bubbles from a button up through its ancestors?

Section 14 — Capturing

Capturing is the first phase of an event's journey, before it reaches its target, in which the event travels down the DOM tree from the window and document, through ancestors, towards the target, firing capturing listeners on each ancestor in turn, from farthest to nearest. Capturing is the mirror image of bubbling: bubbling goes up, from target to ancestors, while capturing goes down, from ancestors to target, and together with the target phase in the middle, they form the full journey of an event through the tree. By default, listeners added with addEventListener() run in the bubbling phase, not the capturing phase, which is why most developers never encounter capturing in ordinary code. Capturing listeners must be explicitly requested, by passing true or { capture: true } as the third argument, as in element.addEventListener("click", handler, true).

Capturing listeners are rarely needed in ordinary front-end work, but they have specific uses worth knowing, because they explain why the option exists. A capturing listener on a container runs before any listener on the target or on intermediate elements, which makes it useful for intercepting an event before it reaches its destination, such as a global handler that needs to decide whether to allow an interaction at all, or to record an event before a child handler might stop its propagation. Since it runs first, it can also call stopPropagation() to prevent the event from ever reaching the target, which is powerful and should be used sparingly, because it can break features that expect their handlers to run, and it can be difficult to debug, because the target's handler simply never fires, with no error. That power, and its debugging cost, are why capturing is an advanced tool, not a default choice.

The full order of a click with both capturing and bubbling listeners is: capturing listeners from the outermost ancestor down to the target's parent, then target listeners, then bubbling listeners from the target's parent up to the outermost ancestor. That order is deterministic and worth picturing as a journey down and then up, with the target at the bottom. In practice, a page rarely has both kinds of listener for the same event on the same chain, so the full order rarely matters, but knowing it helps you understand documentation and debugging output that mentions phases, and why a capturing listener on the document runs before a bubbling listener on a button, even though the button is the target. For this book, the practical takeaway is simple: you do not need capturing for ordinary features, and you should not add it without a reason, because it changes handler order in a way other developers may not expect. If you meet capturing in existing code, check its third argument, understand that it runs on the way down, and consider whether bubbling or delegation could do the job instead.

const outer = document.querySelector(".outer");
outer.addEventListener("click", function () {
  console.log("Capturing: outer runs first, on the way down");
}, true);
outer.addEventListener("click", function () {
  console.log("Bubbling: outer runs last, on the way up");
});
document.querySelector("button").addEventListener("click", function () {
  console.log("Target: button runs in the middle");
});

Check: In the capturing phase, does an ancestor's listener run before or after the target's listener?

Section 15 — stopPropagation

event.stopPropagation() prevents an event from continuing its journey through the DOM tree, so it will not trigger listeners on further ancestors in the bubbling phase, or on further descendants in the capturing phase, depending on where it is called. It does not prevent other listeners on the same element from running; for that there is a stronger method, stopImmediatePropagation(), which also prevents remaining listeners on the same element. For most cases stopPropagation() is the tool needed, because the problem is usually a parent handler running when only a child handler should, not multiple handlers on the same element competing. Calling it in a child's handler is a way of saying "this event has been handled here, and should not be handled again higher up", and it should be used when that intent is genuine, not as a blanket precaution on every handler.

A classic case is a button inside a clickable card, where clicking the card opens a detail view, but clicking the button inside it should perform a different action, such as saving the item, without also opening the detail view. Without a stop, a click on the button bubbles to the card, and both the save and the open actions run, which is confusing and often incorrect, because the user intended only the save. With stopPropagation() in the button's handler, the card's handler never runs for that click, and the save happens alone, matching the user's intent. When the child action is part of the parent's action, such as a click on a list item that should also count as a click on the list for tracking, the event should be allowed to bubble, and no stop is needed. Making that decision per nested interaction, rather than stopping everywhere by default, keeps bubbling working where it is useful, such as global trackers and delegation, which depend on events reaching ancestors.

Stopping propagation can also cause bugs when overused, because it breaks features that rely on bubbling, often in parts of the program far from the handler that stopped the event. A document-level click handler that closes a menu when the user clicks outside it, covered in Section 33, relies on clicks bubbling to the document, and a child handler that stops propagation will prevent that close handler from running, leaving the menu open when it should have closed, with no obvious link between the child's stop and the menu's failure. That kind of distant breakage is why stopping propagation should be used sparingly and documented, with a comment explaining why this event should not bubble, so future developers understand the trade-off. A useful rule is: stop propagation only when you can name the specific parent handler that should not run, and when you have checked that no other parent handler needs the event.

const card = document.querySelector(".card");
card.addEventListener("click", function () {
  console.log("Card opened");
});
card.querySelector("button").addEventListener("click", function (event) {
  event.stopPropagation();
  console.log("Button action only — card does not open");
});

Check: What does stopPropagation() prevent, and what does it not prevent on the same element?

Section 16 — preventDefault

event.preventDefault() stops the browser from performing the default action associated with an event, without stopping the event from propagating to other listeners. Default actions are the built-in behaviours that the browser would perform if no JavaScript intervened: submitting a form, following a link, checking a checkbox, opening a context menu, or inserting a typed character into an input. Preventing the default means your handler takes over that behaviour, replacing it with your own logic, such as validating a form instead of submitting it, or handling a link click with JavaScript instead of navigating. That replacement is the point of many handlers, and it is why preventDefault() is one of the most frequently used event methods, especially for forms and links, where the default action would otherwise reload or navigate away from the page, losing the current state and any JavaScript handling that was meant to run first.

Preventing the default is different from stopping propagation, and confusing the two is a common mistake with real consequences. preventDefault() says "do not do the browser's built-in action for this event", while stopPropagation() says "do not pass this event to other listeners". A form submit handler that calls preventDefault() but not stopPropagation() will still allow other submit listeners on ancestors to run, which is usually fine, because those listeners are part of the same handling chain. A handler that calls stopPropagation() but not preventDefault() will prevent ancestors from hearing the event, but the browser will still perform the default action, such as submitting the form, which is often not what was intended. The two methods are independent, and a handler may need one, the other, both, or neither, depending on whether it needs to replace the default action, prevent further handling, or both. Checking which one a handler actually needs, rather than calling both by habit, keeps event handling precise and avoids breaking other listeners that legitimately need the event.

Some defaults cannot be prevented, or can only be prevented under certain conditions, which is worth knowing so you do not rely on a prevention that will not work. For example, some browser shortcuts and some scroll behaviours are not cancelable, and calling preventDefault() on their events has no effect, which the event's cancelable property tells you: if event.cancelable is false, prevention will not work, and your handler should not assume it has. Similarly, passive listeners, covered in Section 23, cannot call preventDefault(), because they have promised the browser they will not, in exchange for better scrolling performance, and calling it in a passive listener is ignored, with a warning in some browsers. Those limits are edge cases for most code, but they explain why a prevention sometimes seems to fail: check whether the event is cancelable and whether the listener is passive, before assuming the prevention logic itself is wrong, because those two checks explain most "preventDefault did nothing" mysteries.

const form = document.querySelector("form");
form.addEventListener("submit", function (event) {
  event.preventDefault();
  console.log("Form handled in JavaScript, page does not reload");
});
const link = document.querySelector("a");
link.addEventListener("click", function (event) {
  event.preventDefault();
  console.log("Link click handled, navigation prevented");
});
console.log("Is this event cancelable? Check event.cancelable in a handler");

Check: What is the difference between preventDefault() and stopPropagation()?

Section 17 — Event delegation

Event delegation is the technique of attaching a single listener to a parent element, instead of a listener to each child, and using event.target inside the handler to discover which child was actually involved. It works because of bubbling: a click on a child bubbles up to the parent, where the single listener hears it, checks the target, and acts accordingly, as if there had been a listener on the child itself. That single-listener approach has three major advantages. First, it is efficient, because one listener replaces dozens or hundreds, which matters for long lists. Second, it is simpler to manage, because there is one place where the handling logic lives, rather than scattered listeners that must each be attached and removed. Third, and most importantly, it works for children added later, because the parent listener already exists and will hear events from future children as soon as they are added, without any new listener attachment, a benefit covered in Section 19.

A delegated handler must filter the target, because the parent listener hears every bubbling event from every descendant, including clicks on empty space inside the parent, on unrelated children, and on nested elements inside the intended child. The filtering step usually uses event.target.closest(), which walks up from the deepest target to find the nearest ancestor matching a selector, such as closest("li") for a list item or closest("button") for a button, and returns null if no such ancestor exists within the parent. If the result is null, the handler should return early and do nothing, because the click was not on a relevant item. If the result exists, it is the item to act on, regardless of whether the user clicked its text, its icon, or its padding, because closest() normalises all those deepest targets to the same item. Without it, a handler checking event.target directly would work when the user clicks the item's text but fail when they click its icon, because the icon is a different, deeper target.

Delegation is the standard pattern for lists, tables, menus, and any repeated structure where each item needs similar handling with item-specific data. A task list where each item has a delete button, a product grid where each card has a favourite button, and a tab bar where each tab shows a different panel are all delegation candidates, because the items share handling logic and differ only in their data, which can be read from the item itself, often through a data- attribute, as Section 18 shows. The alternative, attaching a listener to each item as it is created, works for static lists, but requires re-attaching listeners on every re-render, which is easy to forget, and fails for items added by other code that does not know to attach listeners. Delegation avoids both problems by placing the listener on a stable parent that is never re-rendered, so handling survives any number of child changes, which is why it pairs so well with the render pattern from Chapter 19, where children are rebuilt frequently but the container stays the same.

const list = document.querySelector("ul");
list.addEventListener("click", function (event) {
  const item = event.target.closest("li");
  if (!item) {
    return; // click was not on an item
  }
  console.log("Clicked item:", item.textContent);
});

Check: Why does delegation use one listener on a parent instead of many listeners on children?

Section 18 — Delegation with data attributes

Delegation becomes powerful when combined with data attributes, because the handler can read item-specific data from the clicked item itself, without searching elsewhere. An item might carry data-id="42" or data-action="delete", and the delegated handler reads item.dataset.id or item.dataset.action to decide what to do, as in deleting the item with that id, or performing the action named by that attribute. That data-on-the-element pattern keeps the handler generic: the same handler works for every item, because the item tells the handler which item it is and what action was requested, through its own attributes, rather than through separate listeners or through text parsing. It also keeps the HTML readable, because the data is visible in the markup, in a standard form, rather than hidden in JavaScript closures or in global variables that map elements to data.

The data-action pattern is a particularly clean form of this idea, for containers with several kinds of buttons. Each button carries a data-action attribute naming its action, such as "delete", "edit", or "favourite", and the delegated handler reads that action and branches accordingly, with an if chain or a switch, calling the appropriate function for each action. That branching handler is easy to extend: adding a new button type means adding a new data-action value in HTML and a new branch in the handler, without attaching a new listener or changing the container's structure. It is also easy to test, because the handler's logic is centralised in one function that takes an action name and an item, rather than scattered across many small handlers. Section 39 returns to this pattern for handling many buttons, but its foundation is here: data attributes as the item's self-description, read by a delegated handler.

A caution about data attributes is that their values are always strings, as Chapter 19 explained, so numeric ids must be converted with Number() before numeric comparison or arithmetic, and boolean-like values must be compared as strings, not as booleans, because "false" as a string is truthy, even though it looks like false. That string-only rule can cause bugs where an id comparison fails because one side is a string from dataset and the other is a number from data, or where a "false" flag is treated as true because it was tested with if (dataset.enabled) instead of if (dataset.enabled === "true"). The fix is to convert at the point of reading, or to compare consistently as strings, and to be explicit about the conversion, so a reader can see that the dataset value was handled as text and then converted, rather than assumed to be already a number or boolean. That explicit conversion is a small habit that prevents a surprising number of delegation bugs, where the handler reads the right attribute but interprets its string value incorrectly.

const list = document.querySelector("ul");
list.addEventListener("click", function (event) {
  const button = event.target.closest("button");
  if (!button) {
    return;
  }
  const action = button.dataset.action;
  const id = Number(button.dataset.id);
  console.log("Action:", action, "Id:", id);
  if (action === "delete") {
    console.log("Delete item", id);
  }
});

Check: How does a delegated handler know which action a clicked button requested?

Section 19 — Handling dynamically added elements

Dynamically added elements are elements created after the page loads, by JavaScript, in response to data changes or user actions, such as new list items added by a render function, or new cards loaded from a server. Direct listeners, attached to elements at setup time, do not apply to dynamically added elements, because those elements did not exist when the listeners were attached, and no code ran to attach listeners to them after their creation, unless the creation code explicitly does so. That is the core problem delegation solves, as Section 17 explained, but it is worth stating as its own section, because it is one of the most common sources of "the new item's button does nothing" bugs, where the original items work perfectly and only the newly added ones fail — a distinctive symptom that should immediately suggest a listener-attachment problem, not a data or rendering problem.

There are two solutions, and delegation is the better one in most cases. The first is to attach listeners at creation time, inside the render or builder function, so every new element gets its listeners as it is built, before it is inserted. That works, and is necessary when an element's handler is truly unique and cannot be expressed as a delegated rule, but it has drawbacks: it couples listener attachment to element creation, so any code path that creates elements must remember to attach listeners, and it requires re-attaching listeners on every re-render, because re-rendering discards old elements and their listeners, as Chapter 19 noted. Forgetting either step produces the "new item does nothing" bug, and the bug can be intermittent, appearing only after a re-render, which makes it harder to reproduce and fix. The second solution, delegation, avoids both drawbacks by placing the listener on a stable parent that is never re-rendered, so new children are covered automatically, and re-renders do not affect the listener at all, because the parent survives them.

Choosing between the two depends on the stability of the parent and the uniqueness of the handling. If the parent is stable and the handling is shared across items, delegation is clearly better, because it is simpler, more robust, and more efficient. If the parent is itself re-rendered, delegation from that parent will fail for the same reason direct listeners fail, and the listener must be placed on a higher, stable ancestor, or re-attached after each parent re-render — a sign that the render boundaries need reconsidering: the container for a delegated list should be a stable element that is cleared and refilled, not replaced, so its listener survives. That stability requirement is a design constraint on render functions for delegated lists: clear the container's children, as Chapter 19's render pattern does, but do not replace the container itself, because replacing it would discard its listener, reintroducing the dynamic-element problem at a higher level.

// Delegation: works for items added later, with no extra code
const list = document.querySelector("ul");
list.addEventListener("click", function (event) {
  const item = event.target.closest("li");
  if (item) {
    console.log("Clicked:", item.textContent);
  }
});
// Later, a new item is added and its clicks are already handled:
const newItem = document.createElement("li");
newItem.textContent = "New task";
list.appendChild(newItem);

Check: Why does a directly attached listener fail for an element added later, while a delegated listener works?

Section 20 — Multiple listeners on one element

An element can have many listeners, for the same event or for different events, and they all run when their event occurs, in the order they were added for the same event type. That multiple-listener ability, introduced in Section 2, is what allows different features to share an element without interfering with each other, and it has implications for ordering, independence, and debugging. When a button click triggers a data update, a display update, and a log message, through three separate listeners, each listener should ideally do one job and not depend on the others' side effects, so features can be added, removed, or reordered without breaking each other. That independence is a design goal, not an automatic guarantee: if one listener relies on another having already updated the data, then order matters, and reordering or removing one could break the other — a hidden coupling to avoid by making each listener read the current state, rather than assuming a previous listener's work.

Order matters in another way: listeners run in registration order, so the first listener added runs first, which is predictable and useful when order is genuinely needed, such as a validation listener that should run before a submission listener. However, relying on registration order for correctness is fragile, because registration order can change when code is reorganised, moved to a different file, or added conditionally, and a feature that breaks when its listener moves earlier or later is a feature with a hidden order dependency. A more robust design is to combine ordered steps into a single handler, where the order is explicit in the code, as in "validate, then submit", rather than splitting them across listeners whose order is implicit in registration sequence. Use multiple listeners for independent features, and a single handler with explicit steps for a single feature with ordered steps, and your event code will stay both modular and reliable.

Debugging multiple listeners can be tricky, because a single event can trigger several handlers, and the visible result is their combined effect, which may be hard to attribute to one handler. Developer tools can help: the Elements panel's Event Listeners tab, in some browsers, lists the listeners attached to a selected element, grouped by event type, letting you see how many listeners exist and where they were defined. A simpler technique is to log a distinctive message at the start of each listener, so the console shows the order and set of listeners that actually ran, which quickly reveals a missing listener, an extra listener, or a listener running twice because it was attached twice — a common bug when setup code runs more than once, such as after a re-render that re-attaches listeners without removing old ones. That double-attachment bug produces a handler running twice per click, with effects doubled, such as two items added instead of one, and its fix is to attach listeners once, at setup, preferably through delegation, rather than on every render.

const button = document.querySelector("button");
button.addEventListener("click", function () {
  console.log("First listener: update data");
});
button.addEventListener("click", function () {
  console.log("Second listener: update display");
});
button.addEventListener("mouseover", function () {
  console.log("Different event, different listener");
});

Check: If two listeners are added for the same event, in what order do they run, and why should they be independent?

Section 21 — Removing a listener

removeEventListener() removes a previously added listener, but it only works if you pass the exact same function reference that was added, along with the same event type and the same capture setting. That exact-reference requirement is why named functions are important for removable listeners: a named function can be referred to again in the removal call, while an anonymous function written inline at addition time cannot be referred to later, because there is no name to pass, and writing an identical-looking anonymous function does not help, because it is a different function object, even if its code is the same. The browser compares function identity, not function text, so two functions with identical code are still different listeners, and removing one does not remove the other. That identity rule explains why code that tries to remove an anonymous listener silently fails, leaving the listener in place, with no error.

Removing listeners matters for cleanup, for temporary features, and for preventing duplicate handling. A drag operation, for example, adds mousemove and mouseup listeners to the document when dragging starts, and removes them when dragging ends, so movement tracking only happens during a drag, not all the time, which saves performance and prevents stray drag behaviour after the drag is over. A modal dialog might add a key listener for Escape while it is open, and remove it when it closes, so Escape does not close a dialog that is no longer there. A feature that is toggled off should remove its listeners, so its handlers do not keep running and affecting a feature the user has disabled. In each case, removal is part of the feature's lifecycle: attach when the feature becomes active, detach when it becomes inactive, and the page's behaviour matches the feature's state, without leftover handlers causing surprises.

Do you need to remove listeners when an element is removed from the page? In modern browsers, no, because once an element is detached and no longer referenced, it becomes eligible for garbage collection, and its listeners go with it, as Chapter 19 noted. Removing listeners explicitly before removing an element is therefore unnecessary in most cases, and can be omitted without causing a leak, as long as you also drop any long-lived references to the element, such as entries in an array or object that outlive it. The leak risk comes from references, not from listeners alone: a listener that closes over a large data structure can keep that structure alive as long as the element and its listener are reachable, but if the element itself is unreachable, the listener is unreachable too, and the whole chain can be collected. So the cleanup habit to prioritise is dropping references to removed elements, with explicit listener removal reserved for listeners on long-lived elements, like document or window, where the element will never be removed and the listener would otherwise run forever.

function handleClick() {
  console.log("Clicked");
}
const button = document.querySelector("button");
button.addEventListener("click", handleClick);
button.removeEventListener("click", handleClick);
// This removal fails: a different function object, even with identical code:
button.addEventListener("click", function () { console.log("Clicked"); });
button.removeEventListener("click", function () { console.log("Clicked"); });

Check: Why must removeEventListener() receive the exact same function that was added?

Section 22 — The once option

The once option makes a listener run only once, and then remove itself automatically. You pass it as part of an options object, as in addEventListener("click", handler, { once: true }), and after the handler runs for the first event, the browser removes the listener, so subsequent events of the same type on the same element do not trigger it again. That automatic removal is convenient for one-time actions, such as a welcome message that should appear on the first click of a button but not on later clicks, a tutorial step that should advance once, or an initialisation that should happen on the first interaction but not repeat. Without once, achieving the same behaviour would require a flag variable, checked and set inside the handler, or an explicit removeEventListener() call inside the handler, both of which are more code and more chances for a mistake, such as forgetting to set the flag. once expresses the intent directly, in the registration itself, so a reader can see at a glance that this handler is one-time, without reading its body for a flag or removal call.

The once option combines with other options, such as capture and passive, in the same options object, as in { once: true, capture: false }, though once is the most commonly used alone. It also works with any event type, not just clicks, so a one-time load handler, a one-time submit handler, or a one-time animation-end handler are all valid uses. A subtle point is that once removes the listener after its first invocation, even if the handler throws an error, because the removal happens as part of the dispatch, not as part of the handler's successful completion, so a failing one-time handler will not run again on the next event. For a one-time action that must succeed, such as a payment submission, relying solely on once for double-submit prevention is therefore not sufficient, and Section 34 covers the additional measures, like disabling the button, that should accompany it. once is a convenience for one-time behaviour, not a guarantee of successful one-time execution.

The once option is also useful for animations and transitions, where a handler should run when an animation ends for the first time but not for later repetitions, and for one-time setup that should happen on the first interaction with a feature, such as showing a hint the first time a panel is opened. In those cases, the handler's job is tied to a first occurrence, not to every occurrence, and expressing that with once makes the intent clear at registration, rather than hiding it in a flag inside the handler. It also avoids a common bug where a flag is forgotten and a one-time action repeats, or where a flag is set in the wrong place and a one-time action never runs, because the option handles the removal automatically and reliably, regardless of what the handler does, as long as it is invoked once.

const button = document.querySelector("button");
button.addEventListener("click", function () {
  console.log("This runs only on the first click");
}, { once: true });
button.addEventListener("click", function () {
  console.log("This runs on every click");
});

Check: What does { once: true } do, and when would you use it?

Section 23 — Passive listeners

A passive listener is one that promises the browser it will not call preventDefault() for its event, in exchange for the browser being able to handle the event more efficiently, especially for scrolling and touch events. You declare a listener passive with { passive: true }, as in window.addEventListener("touchstart", handler, { passive: true }), and the browser then knows it does not need to wait for your handler to finish before starting the scroll or touch action, because your handler cannot cancel it. That non-waiting is a significant performance win for touch scrolling on mobile devices, where waiting for a handler on every touch move could make scrolling feel laggy, because the browser would have to pause the scroll, run JavaScript, check whether it was prevented, and only then continue scrolling, for every move event. With a passive listener, the browser can scroll immediately, in parallel with running your handler, which makes scrolling smooth even when handlers are doing work, such as updating a progress indicator.

The trade-off is that a passive listener cannot prevent the default action, and calling preventDefault() inside one is ignored, with a console warning in some browsers, because the promise has already been made and the browser has already acted on it by starting the scroll. That means passive listeners are suitable only for handlers that observe an event, such as logging, measuring, or updating a display, and not for handlers that need to cancel the event, such as preventing a pull-to-refresh, preventing a zoom gesture, or preventing a scroll in a custom drag interaction, where prevention is the point. For those canceling handlers, the listener must be non-passive, which is the default, and the performance cost of waiting is accepted as necessary. Choosing passive versus non-passive is therefore a decision about whether the handler needs to cancel: observe-only means passive is safe and faster, cancel-needed means non-passive is required.

Modern browsers make some listeners passive by default, notably touchstart, touchmove, and wheel listeners on the document and window, because those events are most commonly used for scrolling and their handlers most commonly do not need to cancel, and the default change was made to improve scrolling performance across the web. That default can surprise developers who add a touchmove listener expecting to prevent scrolling, and find prevention ignored, because the listener was made passive by default. The fix is to explicitly pass { passive: false } when prevention is needed, which overrides the default and restores the ability to cancel, at the cost of the performance benefit. Knowing about the default, and the explicit override, prevents a confusing debugging session where a preventDefault() call seems to do nothing, for a reason that is not in the handler's logic but in the listener's options. For most events, including click and keydown, the default remains non-passive, so prevention works as expected without any options, and passive is an opt-in optimisation for scroll-related events.

window.addEventListener("scroll", function () {
  console.log("Scroll observed, not prevented");
}, { passive: true });
// To prevent a touch scroll, you must explicitly opt out of passive:
document.addEventListener("touchmove", function (event) {
  event.preventDefault();
}, { passive: false });

Check: Why would a passive listener be faster for scrolling, and what can it not do?

Section 24 — Keyboard shortcuts and modifier keys

Keyboard shortcuts combine a main key with one or more modifier keys — Shift, Control, Alt, or Meta — to trigger an action, such as Control plus S for save, or Control plus K for a command palette. Implementing a shortcut means listening for keydown, usually on document for a global shortcut, and checking both the main key and the modifier booleans in the same handler, as in if (event.ctrlKey && event.key === "s"). The check must happen on keydown, not on keyup, because by keyup time the modifier may already have been released, and the combination is no longer current. It must also check the modifiers explicitly, because the same main key without the modifier should do something else, such as typing the letter "s" into a focused input, rather than triggering a save, and a shortcut handler that ignores modifiers will hijack normal typing — a serious usability bug, especially for shortcuts that use ordinary letters without modifiers, which should generally be avoided for global shortcuts for exactly that reason.

A shortcut handler must also consider focus: if the user is typing in a text field, a global shortcut for a letter key should usually not trigger, because the user intends to type, not to activate a shortcut, except for shortcuts explicitly meant to work while typing, such as Escape to close a dialog, or Control plus Enter to submit. The standard guard is to check event.target: if it is an input, textarea, or content-editable element, and the shortcut is not one that should work while typing, return early and let the typing proceed normally. That guard prevents the frustrating experience of typing a document and having letters trigger actions instead of appearing as text. For shortcuts that use Control or Meta plus a key, the guard is less critical, because Control plus a letter is not normal typing, but it is still worth considering, because some inputs, like code editors, may have their own Control shortcuts that should take precedence, and stopping propagation or checking the target can help respect that precedence.

Shortcuts should be discoverable and consistent, which are design considerations, not just implementation ones. A shortcut that exists but is never shown to the user, in a menu, a tooltip, or a help panel, will not be used, and a shortcut that conflicts with a browser shortcut, such as Control plus T or Control plus W, should be avoided, because overriding it with preventDefault() will break a user's expected browser behaviour and may not even work, if the browser reserves that shortcut. Choosing shortcuts that follow platform conventions — Control on Windows and Linux, Meta (Command) on Mac, for the same action — and documenting them in the interface, makes shortcuts a genuine productivity feature rather than a hidden trick.

document.addEventListener("keydown", function (event) {
  const target = event.target;
  const isTyping = target.tagName === "INPUT" || target.tagName === "TEXTAREA";
  if (isTyping) {
    return; // let the user type normally
  }
  if ((event.ctrlKey || event.metaKey) && event.key === "k") {
    event.preventDefault();
    console.log("Command palette shortcut");
  }
});

Check: Why should a global shortcut handler check whether the user is typing in an input?

Section 25 — The Enter key and form behaviour

Pressing Enter in a text field inside a form submits the form, which is a built-in browser behaviour that users expect, especially in short forms like search, login, and comment forms, where Enter is the natural way to confirm. That behaviour is why listening for submit on the form, as Section 10 advised, is better than listening for clicks on its button: the Enter submission generates a submit event, not a button click, so a button-only handler would miss it, while a form handler catches both paths. In a form with a single text field, Enter submits even if there is no visible submit button, because the browser provides an implicit submission, and in a form with multiple fields, Enter in a text field usually submits as well, unless the form has a more complex structure where Enter is expected to insert a newline, as in a textarea, where Enter inserts a line break instead of submitting, which is the correct behaviour for multi-line input and should not be overridden lightly.

Outside a form, pressing Enter does not submit anything, but it does activate the focused button or link, generating a click event, which is the keyboard activation behaviour noted in Section 5. That means a keydown handler for Enter on a button is usually unnecessary, because the button's click handler will already run on Enter, through the browser's activation mechanism, and adding a separate Enter handler risks double-handling, where the action runs twice, once from the key handler and once from the click. The correct approach for a button is to handle click only, and let the browser translate Enter, and Space, into clicks, which it does for native buttons. For a custom control that is not a native button, Enter handling must be added manually, along with focusability and a button role, which is another reason to prefer native buttons: they come with Enter and Space activation built in, tested, and accessible, while custom controls require you to replicate that behaviour yourself.

There are cases where Enter handling is genuinely needed, such as a chat input outside a form, where Enter should send the message, or a search input where Enter should trigger a search without a form submission. In those cases, a keydown handler that checks event.key === "Enter" and then performs the action, often with preventDefault() to stop any default behaviour, is the right tool, but it should be scoped to the specific input, not to the whole document, so Enter in other fields does not trigger the same action unexpectedly. A related case is Shift plus Enter in a chat or comment input, which conventionally inserts a newline instead of sending, and a handler that distinguishes plain Enter from Shift plus Enter, by checking event.shiftKey, can implement that convention, which users of messaging apps expect. That distinction — Enter sends, Shift plus Enter breaks the line — is a small detail that makes a chat input feel familiar, and it is implemented with a two-line check in the keydown handler.

const chatInput = document.querySelector("#chat");
chatInput.addEventListener("keydown", function (event) {
  if (event.key === "Enter" && !event.shiftKey) {
    event.preventDefault();
    console.log("Send message:", chatInput.value);
    chatInput.value = "";
  }
});

Check: Why does a form submit when you press Enter in a text field, and why should you handle submit rather than Enter for forms?

Section 26 — Mouse position and coordinates

Mouse events carry position information, and choosing the right coordinate system matters for features that place elements, draw, or track movement. The event object offers several coordinate pairs, each relative to a different origin, and they diverge when the page is scrolled or when the target element is not at the top-left of the page. clientX and clientY are relative to the visible viewport, so they stay the same for a given screen position even as the page scrolls, because the viewport itself does not move. pageX and pageY are relative to the whole document, including the scrolled-off part, so they increase as you scroll down and click the same screen position, because the document position of that screen spot has moved. offsetX and offsetY are relative to the target element's top-left corner, so they tell you where inside the element the click happened, regardless of where the element sits on the page. screenX and screenY, relative to the user's whole screen, are rarely needed for page features, because they include browser chrome and vary with window position.

The choice among these systems depends on what you will do with the coordinates. For a tooltip that follows the cursor and is positioned with position: fixed, viewport coordinates are correct, because fixed positioning is also relative to the viewport, so the tooltip will track the cursor even as the page scrolls. For an element positioned absolutely within the document, page coordinates are correct, because absolute positioning is relative to the document, and using viewport coordinates there would place the element incorrectly after scrolling, by the scroll amount. For a drawing feature inside a canvas or a box, offset coordinates are correct, because the drawing is relative to that element, and the element's page position is irrelevant. Mixing these systems — for example, using client coordinates for an absolutely positioned element — produces the classic bug where a tooltip or marker is correct at the top of the page but drifts by the scroll amount as the user scrolls, a distinctive symptom that should immediately suggest a coordinate-system mismatch, and the fix is to switch to the system that matches the positioning method.

A practical way to learn the systems is to log all of them for a click on a test element, first at the top of a long page, then after scrolling down and clicking the same element again, if it is still visible, or a similar element lower down. At the top, client and page coordinates will be equal, because no scrolling has offset them, which can hide the difference and lead to code that works in testing at the top but fails after scrolling, a common testing blind spot. After scrolling, client coordinates will be smaller than page coordinates, by exactly the scroll amount, which makes the difference visible and memorable. Offset coordinates, by contrast, will be similar in both cases if you click the same spot inside the element, because they are tied to the element, not to the page or viewport.

const box = document.querySelector(".box");
box.addEventListener("click", function (event) {
  console.log("Client:", event.clientX, event.clientY);
  console.log("Page:", event.pageX, event.pageY);
  console.log("Offset:", event.offsetX, event.offsetY);
});

Check: After scrolling down a page, why do client and page coordinates for the same screen spot differ?

Section 27 — Pointer events and touch

Pointer events are a unified system for mouse, touch, pen, and other pointing devices, designed to replace the separate mouse and touch event systems with a single set of events that work across devices. The main pointer events are pointerdown, pointerup, pointermove, pointerover, pointerout, and pointercancel, which mirror the mouse events but fire for any pointer type, and they carry a pointerType property — "mouse", "touch", or "pen" — so a handler can distinguish the device if needed, for example to give touch users larger targets or to ignore hover effects on touch, where hover does not exist in the same way. Using pointer events instead of separate mouse and touch handlers means a drag feature, for example, can be written once and work with a mouse, a finger, or a stylus, without duplicating logic for each device, which is a significant simplification for features that involve pointing, dragging, or drawing, and it is why pointer events are the recommended approach for new pointing features, rather than the older mouse-plus-touch combination.

Touch events, the older system, include touchstart, touchmove, and touchend, and they differ from mouse and pointer events in that they can involve multiple touches at once, reported as lists of touch points in the event object, which is necessary for multi-touch gestures like pinch-to-zoom, where two fingers move together. Pointer events also support multi-touch, through separate pointer events per touch point, identified by a pointerId, so a multi-touch feature can track each pointer separately, which is a different model from touch events' lists, but one many developers find easier to work with, because each pointer's journey is a separate stream of events. For single-touch features, such as tapping a button or dragging an item with one finger, pointer events are simpler and sufficient, and they also handle mouse input, so a single pointer implementation covers both desktop and mobile — the main reason to prefer them for new code, while recognising touch events when you read older mobile code that predates pointer events or needs multi-touch list handling in the touch-event style.

A detail of pointer and touch handling is that touch interactions can also generate mouse events, for compatibility, in a sequence known as emulated mouse events, where a tap on a touch device fires touch events followed by mouse events, including a click, so sites written only for mouse still work on touch devices. Pointer events avoid that double-handling, because a tap generates pointer events only, not a separate mouse stream, and a click still fires for compatibility, but a feature that handles pointer events should not also handle mouse events for the same action, or it will double-handle in the same way. The guideline is to choose one system per feature — pointer events for pointing, mouse events for mouse-only features, touch events only when multi-touch lists are specifically needed — and to stick to it, rather than mixing systems for the same action, which is the usual cause of double-trigger bugs on touch devices.

const pad = document.querySelector("#pad");
pad.addEventListener("pointerdown", function (event) {
  console.log("Pointer type:", event.pointerType, "Id:", event.pointerId);
});
pad.addEventListener("pointermove", function (event) {
  console.log("Pointer at:", event.clientX, event.clientY);
});

Check: Why do pointer events simplify a drag feature that should work with both mouse and touch?

Section 28 — Double-click and context menu

Double-click, the dblclick event, fires when a user clicks twice quickly on the same element, and it is used for actions distinct from a single click, such as opening an item for editing, zooming, or selecting a word. The timing that counts as a double-click is determined by the operating system and browser, not by your code, and it is usually a few hundred milliseconds between clicks, which means your code does not need to measure the interval itself; the browser does it and fires dblclick when the interval qualifies. A complication is that a double-click also generates two single click events, one for each click, before the dblclick fires, so a handler for both click and dblclick on the same element will see click, click, double-click, in that order, and a single-click action that should not run on a double-click will run twice before the double-click action runs, which is often not desired.

The context menu event, contextmenu, fires when a user right-clicks, or uses the keyboard context-menu key, on an element, and its default action is to show the browser's context menu, with options like back, reload, and inspect. Preventing that default with preventDefault() allows a page to show its own custom context menu, such as a file manager's right-click menu with options like rename and delete, which is a legitimate use, but it should be done thoughtfully, because users rely on the browser's context menu for navigation, for opening links in new tabs, and for accessibility tools, and replacing it entirely can remove functionality they expect. A custom context menu should therefore be reserved for applications where right-click actions are a core part of the interface, like a diagram editor or a file manager, and it should provide equivalent functionality for keyboard users, who cannot right-click, through a menu button or a keyboard shortcut, so the feature is not mouse-only. For ordinary pages, where right-click has no special meaning, the browser's menu should be left alone, and no contextmenu handler is needed at all.

Both dblclick and contextmenu are less common than click, and they should be used sparingly, because they are less discoverable than a visible button: users may not know that double-clicking or right-clicking does something, unless the interface hints at it, and users on touch devices may not have a double-click or a right-click at all, or may access them through gestures that are not obvious. A feature only available through double-click or right-click is therefore inaccessible to many users, and it should always have a visible, keyboard-accessible alternative, such as a button that performs the same action, with the double-click or right-click as a shortcut for experienced users, not as the sole path.

const item = document.querySelector(".item");
item.addEventListener("dblclick", function () {
  console.log("Double-click: open for editing");
});
item.addEventListener("contextmenu", function (event) {
  event.preventDefault();
  console.log("Custom menu would appear at:", event.clientX, event.clientY);
});

Check: Why does a double-click also trigger two single click events, and what problem does that cause?

Section 29 — Debouncing input

Debouncing is a technique for waiting until a rapid series of events pauses, before running a handler, so the handler runs once, after the user has finished a burst of activity, rather than once per event in the burst. It is most commonly used for search inputs, where typing a ten-letter query would otherwise trigger ten searches, one per keystroke, most of which are for incomplete queries the user does not care about, wasting network requests and processing. A debounced search handler waits, for example, 300 milliseconds after the last keystroke, and only then runs the search, so a user typing quickly triggers one search for the full query, after they pause, rather than ten searches for partial queries. That waiting is implemented with a timer: each new event clears the previous timer and starts a new one, so the handler only runs when a full interval passes without a new event — the definition of a debounce.

The debounce interval is a trade-off between responsiveness and efficiency. A short interval, like 150 milliseconds, feels responsive, because results appear soon after the user pauses, but it may still trigger multiple searches for a slowly typed query, because pauses between keystrokes can exceed 150 milliseconds even while the user is still typing. A long interval, like 500 milliseconds, is more efficient, because it waits longer and therefore triggers fewer searches, but it can feel laggy, because the user must wait half a second after pausing before results appear. A common starting point is 300 milliseconds, which balances the two, but the right value depends on the feature: a local filter, which is cheap, can use a shorter interval or no debounce at all, while a network search, which is expensive, benefits from a longer interval. Measuring with real typing, rather than guessing, is the way to tune the interval, because typing speed varies.

Debouncing is implemented with setTimeout() and clearTimeout(), in a small pattern worth learning by heart, because it appears in many codebases. A variable holds the timer id, initially empty. Each event clears the existing timer, if any, with clearTimeout(), and then sets a new timer with setTimeout(), whose callback runs the actual handler after the interval. That clear-and-reset on every event is the whole mechanism: as long as events keep coming faster than the interval, the timer never completes, and the handler never runs, until events stop for a full interval, at which point the timer completes and the handler runs once, with the latest value. That latest-value detail is important: the debounced handler should read the current input value at the time it runs, not the value from the first event in the burst, because the user may have typed more since then, and the search should be for the final query, not an intermediate one. Reading the value inside the timer callback ensures that.

const search = document.querySelector("#search");
let timer;
search.addEventListener("input", function () {
  clearTimeout(timer);
  timer = setTimeout(function () {
    console.log("Search for:", search.value);
  }, 300);
});

Check: How does debouncing turn ten keystrokes into one search, and why does it read the value inside the timer?

Section 30 — Throttling scroll and resize

Throttling is a technique for limiting a handler to at most once per interval, even while events keep coming, so the handler runs regularly during a burst of activity, but not on every single event. It differs from debouncing, which waits until activity pauses, while throttling runs during activity, at a controlled rate, which is what features like scroll progress indicators, live layout adjustments, and resize handling need, because they should update while the user is scrolling or resizing, not only after they stop. A throttled scroll handler, for example, might run at most once every 100 milliseconds, updating a progress bar as the user scrolls, which feels live, because it updates several times per second, but it does not run on every scroll event, which could be dozens per second, wasting work on intermediate positions the user passes through quickly. That regular-but-limited rate is the definition of throttling, and it balances responsiveness with efficiency, in a different way from debouncing's wait-until-pause approach.

The choice between throttling and debouncing depends on whether the feature needs updates during the activity or only after it. A search needs only the final query, so debouncing, which waits until typing pauses, is correct, because intermediate queries are useless. A scroll progress bar needs updates during scrolling, so throttling, which runs regularly during scrolling, is correct, because waiting until scrolling stops would leave the bar frozen during the scroll, defeating its purpose. A resize handler that adjusts a layout needs updates during resizing to feel live, so throttling is appropriate, while a resize handler that saves a final window size to storage needs only the final size, so debouncing is appropriate, because intermediate sizes are irrelevant. Asking "does this feature need intermediate values, or only the final value?" therefore picks the technique, and many features that are incorrectly debounced, showing no feedback during an action, or incorrectly throttled, doing work for intermediate values that are immediately discarded, can be fixed by swapping the technique to match the need.

Throttling is implemented with a timestamp or a flag that records when the handler last ran, and a check on each event for whether enough time has passed since that last run. If enough time has passed, the handler runs and the timestamp updates; if not, the event is ignored, or, in a trailing-throttle variant, a timer is set to run the handler at the end of the interval with the latest value, so the final state is not missed. Utility libraries provide debouncing and throttling functions, so you do not need to write them from scratch in production code, but understanding their mechanisms helps you choose and configure them correctly, and helps you debug features where the timing feels wrong, because you can reason about whether the handler is waiting too long, running too often, or missing the final value.

let lastRun = 0;
window.addEventListener("scroll", function () {
  const now = Date.now();
  if (now - lastRun >= 100) {
    lastRun = now;
    console.log("Throttled scroll at:", window.scrollY);
  }
});

Check: What is the difference between throttling and debouncing, and when would you choose each?

Section 31 — Custom events

Custom events are events you define yourself, with your own type name, which you can dispatch from an element and listen for with addEventListener(), just like built-in events. They are useful for communication between different parts of a program that should not be directly coupled: one part dispatches a custom event when something happens, such as a user logging in, a cart updating, or a theme changing, and other parts listen for that event and react, without the dispatching part needing to know who is listening or what they will do. Defining those moments as events lets you use the same listen-and-respond pattern for application logic as for user interaction, which keeps code consistent and modular.

Creating a custom event uses the CustomEvent constructor, as in new CustomEvent("cart-updated", { detail: { count: 3 } }), where the first argument is the type name, which should be a descriptive, hyphenated string that does not clash with built-in event names, and the detail property in the options object carries data about the event, which listeners can read as event.detail. Dispatching uses element.dispatchEvent(event), which synchronously calls all listeners for that type on that element and, if the event is configured to bubble, on its ancestors, just like a built-in event, except that custom events do not bubble by default, so you must pass { bubbles: true } if you want parent listeners to hear them, which is usually desired for application events that should be heard broadly. That bubbling opt-in is a detail to remember, because a custom event dispatched on a child that is listened for on a parent will silently not arrive if bubbling was not enabled, which looks like the listener was never attached, when in fact the event simply did not travel to it.

Custom events are particularly useful for component communication in larger pages, where a child component, such as a product card, needs to inform a parent component, such as a cart, that something happened, without the child knowing about the cart's implementation. The child dispatches a "product added" event with the product's id in its detail, and the cart listens for that event and updates itself, which keeps the child and the cart independent: the child can be reused in a different page with a different cart, as long as the new cart listens for the same event, and the cart can be changed without changing the child, as long as it still listens for the same event type and detail shape. That contract — event type plus detail shape — is the interface between components, and it is simpler and more flexible than direct function calls between components, which would couple them tightly. For small pages, direct function calls are simpler and sufficient, and custom events are unnecessary complexity, so they should be introduced when decoupling is genuinely needed, not as a default for every interaction, because overusing them can make data flow hard to follow, with events flying between components in ways not visible in the code's call structure.

const button = document.querySelector("button");
button.addEventListener("cart-updated", function (event) {
  console.log("Cart count:", event.detail.count);
});
const custom = new CustomEvent("cart-updated", {
  detail: { count: 3 },
  bubbles: true
});
button.dispatchEvent(custom);

Check: Why would you define a custom event instead of calling a function directly?

Section 32 — Dispatching data with CustomEvent detail

The detail property of a custom event is how you pass data from the dispatcher to the listeners, and it can hold any value: a number, a string, an object, or an array, though an object with named properties is the most common and most readable, because listeners can then read event.detail.count or event.detail.id by name, rather than guessing the meaning of a bare value. Designing the detail shape is part of designing the event's contract, and it should be consistent and documented, even if only in a comment, so all listeners know what data to expect and dispatchers know what data to provide. A detail shape that changes between dispatches, sometimes holding a count and sometimes holding a whole object, will break listeners that assume one shape, so the shape should be fixed for a given event type, with optional properties for data that is not always present, rather than varying the type of a property between dispatches. That consistency is the same principle as consistent object shapes in Chapter 18, applied to event data, and it prevents bugs where a listener reads a property that is missing or of the wrong type for a particular dispatch.

Dispatching is synchronous, which means dispatchEvent() calls all listeners immediately, in order, before returning, unlike a network request or a timer, which complete later. It also means the dispatcher can know, after dispatchEvent() returns, that all listeners have run, which is useful for sequential logic, such as dispatching a "before save" event, letting listeners validate or modify the data, and then proceeding with the save, knowing validation has already happened.

A custom event's detail should contain data, not references to DOM elements or to large, mutable objects that listeners might change, unless that mutation is intentional, such as a "before" event where listeners are expected to modify the detail to influence the dispatcher's next step. For most "something happened" events, the detail should be a snapshot of the relevant data at dispatch time, such as the new count, the added item's id, or the changed setting's value, so listeners act on a stable, known value, rather than on a live object that might change before they read it. If the detail holds an object, listeners that mutate it will affect other listeners that run after them, because they share the same object, which can be a feature for cooperative modification, but is more often a bug, where one listener's change surprises another. Copying the detail object per listener is not done automatically, so the discipline is to treat detail as read-only unless the event is explicitly documented as modifiable, and to dispatch a fresh object for each dispatch, rather than reusing a single object across dispatches, which would allow stale mutations to leak into later events.

document.addEventListener("user-signed-in", function (event) {
  console.log("Welcome,", event.detail.name);
});
const signedIn = new CustomEvent("user-signed-in", {
  detail: { name: "Ada", id: 42 },
  bubbles: true
});
document.dispatchEvent(signedIn);

Check: What kind of value can detail hold, and why should its shape stay consistent for one event type?

Section 33 — Click outside to close

Click outside to close is a pattern where a panel, menu, or dialog closes when the user clicks anywhere outside it, which is a common expectation for dropdowns, popovers, and modals that are not modal in the strict sense of trapping focus. It is implemented with a listener on document that runs on every click, checks whether the click's target is inside the panel, and closes the panel if it is not. The check uses contains(), as in if (!panel.contains(event.target)), which returns true if the target is the panel itself or a descendant of it, and false otherwise, so a click on the panel or its children keeps it open, while a click anywhere else closes it. That single check is the whole mechanism, and it relies on bubbling, because the document listener hears clicks from everywhere on the page, through bubbling, and then filters them by containment, which is a form of delegation where the "item" is the panel and the action is "close if outside".

The pattern has several details that must be handled to work correctly. First, the button that opens the panel must not immediately trigger the close, which can happen if the open button is outside the panel and its click bubbles to the document listener, which then sees a click outside the panel and closes it again, undoing the open in the same click. The fixes are to include the open button in the containment check, by checking both the panel and the button, as in if (!panel.contains(target) && !button.contains(target)), or to stop the button's click from propagating to the document, with stopPropagation(), though the containment check is usually cleaner, because it does not break other document listeners that might need the click. Second, the document listener should only be active while the panel is open, to avoid unnecessary work on every click when no panel is open. Adding the listener when the panel opens and removing it when it closes, as Section 21 describes, is the clean lifecycle for this pattern, and it also prevents multiple document listeners accumulating if the panel is opened repeatedly without cleanup.

A related consideration is keyboard handling for the same panel: a user who opened a panel with a keyboard should be able to close it with Escape, which is a separate listener for keydown on Escape, that closes the panel and returns focus to the open button, as Chapter 19's focus guidance suggests. Implementing both, and testing both, is therefore part of a complete click-outside feature, not an optional extra, and it is a good example of how a seemingly simple mouse pattern has a keyboard counterpart that must be designed alongside it, rather than added later as an afterthought.

const panel = document.querySelector("#panel");
const button = document.querySelector("#open-panel");
function handleDocumentClick(event) {
  if (!panel.contains(event.target) && !button.contains(event.target)) {
    panel.hidden = true;
    document.removeEventListener("click", handleDocumentClick);
  }
}
button.addEventListener("click", function () {
  panel.hidden = false;
  document.addEventListener("click", handleDocumentClick);
});

Check: How does a document click handler know whether a click was outside a panel?

Section 34 — Preventing double submission

Double submission happens when a user submits a form twice, either by double-clicking the submit button, by pressing Enter while the submission is already in progress, or by clicking submit again because the first submission seemed slow and gave no feedback. The result can be duplicate orders, duplicate comments, duplicate payments, or other duplicated side effects, which are serious for any feature that creates something on a server, because the server may not know the second submission is a duplicate, unless it has idempotency protection, a server-side concept beyond this chapter's scope. On the front end, the standard prevention is to disable the submit button immediately on the first submission, and to show feedback that the submission is in progress, such as changing the button's text to "Submitting..." or showing a spinner, so the user knows their action was received and does not feel the need to click again.

Disabling alone is not sufficient for all double-submission paths, because a form can also be submitted by pressing Enter, which does not go through the button's disabled state in the same way, and because a user could re-enable the button through developer tools, though that is not a normal user path. A more robust front-end guard is a flag variable, such as let isSubmitting = false, checked at the start of the submit handler, set to true on the first submission, and reset to false when the submission completes or fails, so subsequent submit events while the flag is true are ignored, regardless of whether they came from a button click, an Enter press, or another path. The reset-on-failure detail is easy to forget, and is a common cause of "submit button stopped working after an error" bugs, where the flag or disabled state was never cleared after the error path, leaving the form stuck.

The once option, covered in Section 22, is not a sufficient double-submit guard on its own, because it prevents the listener from running a second time, but it does not prevent a second submission through a different path, such as Enter after a button click, if those paths use different listeners, and it does not give the user feedback or a way to retry after a failure, because the listener is gone after the first run, even if that run failed. A better design is a submit handler that is always attached, guarded by a flag and by button disabling, with feedback and with reset on completion or failure, which handles all paths uniformly and allows retries after failures. Practising that state machine on a test form, with a simulated slow submission and a simulated failure, teaches the full double-submit problem, not just the double-click symptom.

const form = document.querySelector("form");
let isSubmitting = false;
form.addEventListener("submit", function (event) {
  event.preventDefault();
  if (isSubmitting) {
    return; // ignore duplicate submissions
  }
  isSubmitting = true;
  const button = form.querySelector('button[type="submit"]');
  button.disabled = true;
  button.textContent = "Submitting...";
  // Simulate an async submission, then reset on failure or completion:
  setTimeout(function () {
    isSubmitting = false;
    button.disabled = false;
    button.textContent = "Submit";
  }, 1000);
});

Check: Why is disabling the submit button alone not enough to prevent all double submissions?

Section 35 — Choosing the right event

Choosing the right event means matching the event type to the user's intention and to the element's nature, rather than using the most familiar event for every feature. A button's activation should use click, not mousedown, because click represents the completed intention and includes keyboard activation, as Section 5 explained. A text field's live feedback should use input, not change, because input fires on every keystroke, as Section 9 explained. A form's submission should use submit on the form, not click on its button, because submit covers keyboard submission as well, as Section 10 explained. A field's validation on completion should use blur or change, not input, because those fire after the user has finished, rather than interrupting mid-typing. Each of those choices is a small decision, but together they determine whether a feature feels natural or feels slightly wrong, with feedback at the wrong time, missed keyboard paths, or double-handling, all of which users notice even if they cannot name the cause.

A useful way to choose is to ask three questions about the feature. First, what is the user's intention: to activate, to type, to choose, to submit, to move, or to leave? The intention maps to an event family: activation to click, typing to input or keydown, choosing to change, submitting to submit, moving to mouse or pointer events, leaving to blur or mouseout. Second, when should the handler run: immediately on every change, after a pause, on completion, or on a specific key? The timing maps to a specific event within the family, or to a debounced or throttled version of it, as Sections 29 and 30 explained. Third, which element should the listener be attached to: the element itself, its form, its container for delegation, or the document for a global shortcut? The attachment point maps to the scope of the feature, with delegation for lists and document for globals, as Sections 17 and 24 explained.

Testing the choice with both mouse and keyboard is essential, because an event that works for mouse may fail for keyboard, and vice versa, and a feature that only works for one input method is incomplete, even if it works perfectly for the method you tested with. A button feature tested only with mouse clicks may miss that keyboard users cannot trigger it if it listens for mousedown, while a shortcut feature tested only with keyboard may miss that mouse users have no way to trigger it at all, and need a visible button as well. It also reinforces the accessibility principle that runs through this chapter: events are not just about code, they are about how different users interact, and the right event is the one that matches all the intended interaction methods, not just the one the developer happened to use while testing.

// Right event for each intention:
document.querySelector("#save").addEventListener("click", function () {
  console.log("Save activated — works for mouse and keyboard");
});
document.querySelector("#search").addEventListener("input", function (event) {
  console.log("Live search:", event.target.value);
});
document.querySelector("form").addEventListener("submit", function (event) {
  event.preventDefault();
  console.log("Form submitted — covers click and Enter");
});

Check: For a live search as the user types, why is input a better choice than change or click?

Section 36 — Named handler functions

Named handler functions, declared with a name and passed to addEventListener() by that name, are the clearest form for most handlers, because the name documents the handler's job, the function appears by name in error messages and stack traces, and it can be removed later with removeEventListener(), as Section 21 explained. A handler named handleSave tells a reader, at the registration site, that clicking this button saves, without needing to read the handler's body, while an anonymous function at the same site requires reading the body to know what the click does, which is slower for a reader scanning a file for a feature. That readability benefit grows with codebase size, where names act as a table of contents for what each listener does, allowing a reader to find the relevant handler by name. Naming also helps debugging, because a stack trace showing handleSave is immediately more informative than one showing "anonymous function".

Named handlers also encourage reuse and testing, because a function with a name can be called directly, from a test or from another part of the program, without triggering an event, which is useful for testing the handler's logic in isolation, or for invoking the same logic from a different event, such as calling a save handler from both a button click and a keyboard shortcut, without duplicating its code. That reuse is cleaner than duplicating the handler's body in two anonymous functions, which would then need to be kept in sync when the logic changes. A named handler can also be passed to multiple listeners, for different events or elements, which is a form of reuse anonymous functions cannot easily achieve, because each anonymous function is a separate object, even if its code is identical, and updating one does not update the others. For handlers used in more than one place, or long enough to deserve a name for readability alone, a named function is the right choice, and it should be the default for handlers longer than a few lines.

There are cases where an anonymous or arrow function is appropriate, and recognising them prevents named functions from becoming overkill. A very short, one-off handler used in only one place, such as logging a message or toggling a single class, can be written inline without harming readability, because its body is short enough to read at the registration site, and its job is obvious from that body. In those cases, creating a named function elsewhere in the file, far from its single use, can actually hurt readability, by forcing the reader to jump between the registration and the definition to understand a trivial action. The guideline is therefore a balance: name handlers that are long, reused, removable, or important enough to deserve a descriptive label, and write short, one-off handlers inline, where their brevity makes the registration site self-explanatory. That balance gives you the readability of names where they matter, without the clutter of names where they do not.

function handleSave() {
  console.log("Saving...");
}
function handleCancel() {
  console.log("Cancelling...");
}
document.querySelector("#save").addEventListener("click", handleSave);
document.querySelector("#cancel").addEventListener("click", handleCancel);
// Short one-off handler, inline is fine:
document.querySelector("#help").addEventListener("click", function () {
  console.log("Help shown");
});

Check: Why is a named handler easier to debug and to remove than an anonymous one?

Section 37 — Arrow functions and this in handlers

Arrow functions do not get their own this, which means that inside an arrow-function handler, this refers to whatever this was in the surrounding scope where the arrow function was defined, not to the element the listener is attached to. Traditional functions, by contrast, get their this set by the browser to the element the listener is attached to, which is event.currentTarget, so inside a traditional handler, this is a convenient shorthand for the listening element, as in this.classList.toggle("open") for a button that toggles itself.

The fix for arrow handlers that need the element is to use event.currentTarget instead of this, which works in any function form, because currentTarget is a property of the event object, not a binding of the function, and it always refers to the listening element, regardless of whether the handler is an arrow function or a traditional function. That explicit form is actually preferable in modern code, because it does not depend on the function's form, and it makes the intent clear: "the element this listener is attached to", rather than relying on the implicit this rule, which a reader must know to understand. For handlers that need the target element, event.target is the corresponding explicit form, though it may differ from currentTarget in delegated handlers, as Section 12 explained, so the choice between target and currentTarget in an arrow handler should follow the same rule as in any handler: target for what was interacted with, currentTarget for where the listener lives. Using those explicit properties makes arrow handlers safe and clear, and removes the main reason to avoid arrow functions for handlers.

Arrow functions are still useful for handlers, especially for short handlers that do not need this, or that need to preserve the surrounding this deliberately, such as a handler inside an object method or a class, where this should refer to the object, not to the element, and an arrow function captures that outer this correctly, while a traditional function would rebind it to the element and break the object's logic. That capturing behaviour is why arrow functions are popular in framework code and in object methods, where the handler is part of a larger object and needs access to that object's properties. For plain, top-level handlers that operate on the element itself, a traditional function with this, or an arrow function with event.currentTarget, are both fine, and the choice is stylistic, as long as it is consistent and the this rule is understood. The bug to avoid is mixing the two models unconsciously: writing an arrow function and then using this as if it were a traditional function, which is the most common arrow-handler mistake.

const button = document.querySelector("button");
button.addEventListener("click", function () {
  console.log(this === button); // true — traditional function, this is the element
});
button.addEventListener("click", function (event) {
  console.log(event.currentTarget === button); // true — works in any form
});
button.addEventListener("click", () => {
  console.log(this); // NOT the button — arrow function keeps outer this
});

Check: Inside an arrow-function handler, what does this refer to, and what should you use instead to get the element?

Section 38 — Common mistake: calling handler immediately

A common mistake is to pass a handler with parentheses at registration time, as in addEventListener("click", handleClick()), which calls the handler immediately, once, during setup, and registers its return value, usually undefined, as the listener, so nothing runs when the event actually occurs. The correct form passes the function itself, without parentheses, as in addEventListener("click", handleClick), leaving the parentheses for the browser to add later, when the event occurs and the handler should run. The mistake is easy to make, because most other places where you use a function, you do call it with parentheses, and the registration site looks like a place where you would call it, but it is actually a place where you hand it over for later calling, which requires the reference, not the call.

The symptom of this mistake is distinctive and worth learning to recognise immediately: the handler's effect happens once, at page load, before any user interaction, and then never again, no matter how many times the user triggers the event. A greeting that appears on load instead of on click, a list that renders once at setup instead of on button press, or a log message that appears once in the console at load and never on click, are all signatures of an immediate-call bug, because the handler ran at registration, when the page loaded, and its return value, which is not a function, was registered as a listener, so later events have no function to call. Checking the registration line for parentheses is the first diagnostic when a handler runs at load instead of on event, and it takes seconds once you know to look for it, compared to the confusion of assuming the listener was never attached or that the event never fired, when in fact the handler did run, just at the wrong time, once.

There is a legitimate case where parentheses appear at registration, and it is important to distinguish it from the mistake: when you need to pass arguments to a handler, you cannot pass the function directly, because it would receive only the event object, not your arguments, so you wrap it in an anonymous function that calls it with the arguments, as in addEventListener("click", function () { handleClick(42); }). In that wrapped form, the outer anonymous function is passed by reference, without parentheses, and it calls the inner handler with parentheses, but only when the event occurs, because the outer function's body does not run until then. That wrapping pattern is correct and common, and it is the solution when a handler needs extra data beyond the event object, such as an id or a configuration value. The mistake is only when the handler itself is called at registration, without a wrapper, so it runs immediately and its result is registered. Recognising the wrapper pattern as correct, and the direct call as incorrect, lets you pass arguments safely without falling into the immediate-call trap.

function handleClick() {
  console.log("Clicked");
}
// Wrong: calls immediately, registers undefined
// document.querySelector("button").addEventListener("click", handleClick());
// Correct: passes the function for later calling
document.querySelector("button").addEventListener("click", handleClick);
// Correct with arguments: wrapper function, called only on click
document.querySelector("button").addEventListener("click", function () {
  console.log("Clicked with an argument");
});

Check: What happens when you write addEventListener("click", handleClick()) instead of addEventListener("click", handleClick)?

Section 39 — Handling many buttons with data-action

Handling many buttons with a data-action attribute and a single delegated handler is a clean pattern for toolbars, lists, and menus where each button performs a different action but shares the same container. Each button carries a data-action value naming its action, such as "delete", "edit", "save", or "share", and the container's delegated handler reads that value from the clicked button, via closest("button") and dataset.action, and branches to the appropriate function, as Section 18 introduced. Adding a new button type then means adding a button with a new data-action value and a new branch in the handler, without touching the listener attachment code at all, which is a maintainable structure for a growing toolbar.

The branching logic inside the handler can be written as an if chain, a switch statement, or an object mapping action names to functions, and the object mapping is often the cleanest for many actions, because it turns the branch into a lookup: const actions = { delete: deleteItem, edit: editItem }; const fn = actions[action]; if (fn) fn(item);. That lookup form is concise, it avoids a long chain of conditionals, and it makes it easy to see which actions are supported, by listing the object's keys, though it requires that the action functions exist and are named consistently, which is a small discipline that pays off in readability. Whichever branching form you choose, the handler should also handle unknown actions gracefully, by ignoring them or logging a warning, rather than crashing, because a typo in a data-action value, or a button added by another part of the program with an action the handler does not know, should not break the whole toolbar, and a warning in the console will help the developer find the typo, while an ignored unknown action keeps the rest of the toolbar working.

Data-action handling also needs the item context, not just the action name, because most actions operate on a specific item, such as deleting a specific task or editing a specific product. That context usually comes from the button's container item, found with closest() from the button, and from that item's data attributes, such as data-id, which identify which item the action applies to, as Section 18 showed. The handler therefore does two lookups: first, the button, to get the action, and second, the item, to get the target, and then it calls the action function with the item or its id, as in deleteItem(id). That two-lookup pattern — button for action, item for target — is the complete data-action pattern, and it works for any container where buttons act on items, from task lists to product grids to comment threads, with the same handler structure, differing only in the action names and the item functions.

const toolbar = document.querySelector("#toolbar");
toolbar.addEventListener("click", function (event) {
  const button = event.target.closest("button");
  if (!button) {
    return;
  }
  const action = button.dataset.action;
  const item = button.closest("li");
  console.log("Action:", action, "on item:", item ? item.textContent : "none");
  if (action === "delete" && item) {
    item.remove();
  }
});

Check: In a data-action handler, how do you find both the action and the item it applies to?

Section 40 — Reading form values in a handler

Reading form values in a handler means finding the relevant controls, reading their current state properties — value for text and selects, checked for boxes and radios, as Chapter 19 explained — and using those values to update data, validate, or send a request. The handler should read values at the moment it runs, not at setup time, because values change as the user types and interacts, and a value read at setup would be stale by the time the handler runs, reflecting the form's initial state rather than the user's current input.

For a submit handler, the values are usually read from the form as a whole, either field by field or with FormData, as Section 10 described, and then validated before use, because user input cannot be trusted to be present, correctly formatted, or within expected ranges, and validation is the handler's responsibility before the data is used or sent. For an input handler that provides live feedback, the value is read from event.target, which is the control itself, and used immediately, such as updating a character count or a preview, without needing to query for the control again, because the event already tells you which control changed. Those three reading patterns — form-wide, nearby-input, and event-target — cover most handler value reading, and choosing among them depends on whether the handler is triggered by a form, by a button near inputs, or by the input itself.

Validation and trimming are part of reading values well, not separate steps to be added later. Text values should usually be trimmed of leading and trailing whitespace, because users often add accidental spaces, and a trimmed value is what they intended, while an untrimmed value can cause failed lookups, duplicate entries, or validation that passes for a string of spaces, which should be treated as empty. Empty values should be handled explicitly, either by showing an error, by ignoring the action, such as ignoring an empty task addition, or by using a default, rather than by letting an empty string flow into data and display as a blank item, which is confusing and hard to clean up later. Numeric values read from inputs arrive as strings, so they must be converted with Number() before maths or numeric comparison, otherwise "10" plus "2" will concatenate to "102" instead of adding to 12, a classic string-number bug that appears whenever input values are used in calculations without conversion. Those three habits — trim, check empty, convert numbers — applied at the point of reading, prevent a large share of form-handling bugs, and they are cheap to apply consistently, because they are the same three steps for every text or numeric input, regardless of the feature.

const button = document.querySelector("#add-task");
button.addEventListener("click", function () {
  const input = document.querySelector("#task-input");
  const text = input.value.trim();
  if (!text) {
    return; // ignore empty input
  }
  console.log("New task:", text);
  input.value = "";
  input.focus();
});

Check: Why should a handler read form values inside the handler, at event time, rather than at setup time?

Section 41 — Validating on submit

Validating on submit means checking a form's values when the user tries to submit it, and preventing submission if any value is invalid, while showing errors for the invalid fields. Submit validation should be comprehensive, covering required fields, formats like email addresses, ranges like ages or quantities, and cross-field rules like password confirmation matching, because it is the last check before data is used or sent, and it must catch anything earlier, live validation might have missed, or that the user skipped by not interacting with a field at all, such as a required field they never focused, which live validation on input or blur would never have checked.

The submit handler's validation flow follows a clear order: prevent the default submission first, as Section 10 advised, then check each field, collecting errors, then, if there are errors, display them and stop, without proceeding to the submission logic, and if there are no errors, proceed. Displaying errors means more than logging them to the console: it means showing a message near each invalid field, or in a summary at the top of the form, with text that says what is wrong and how to fix it, such as "Email is required" or "Password must be at least 8 characters", rather than a generic "Invalid form", which does not help the user correct the problem. It also means moving focus to the first invalid field, so keyboard users can start correcting immediately, and marking invalid fields with aria-invalid="true" and with an error class, so both assistive technology and styling can reflect the invalid state. A validation that only prevents submission, without showing clear, field-specific errors and focus, is incomplete, because the user may not know why the form did not submit, especially if the invalid field is off-screen.

Browser built-in validation, through attributes like required, type="email", min, max, and pattern, provides a first layer of validation without JavaScript, and it should be used alongside JavaScript validation, not instead of it, because built-in validation gives immediate, accessible feedback with native messages, while JavaScript validation gives custom messages, cross-field checks, and control over display that built-in validation does not offer. A form with required attributes will not fire its submit event at all if a required field is empty, because the browser blocks submission and shows its own message, which means a JavaScript submit handler that expects to run for validation will not run for that case, and the handler must be designed with that in mind, either by relying on built-in validation for required fields and using JavaScript for the rest, or by adding novalidate to the form to disable built-in validation and handle all validation in JavaScript, with custom messages for every case.

const form = document.querySelector("form");
form.addEventListener("submit", function (event) {
  event.preventDefault();
  const email = form.querySelector("#email");
  const error = form.querySelector("#email-error");
  if (!email.value.trim()) {
    error.textContent = "Email is required";
    email.setAttribute("aria-invalid", "true");
    email.focus();
    return;
  }
  error.textContent = "";
  email.removeAttribute("aria-invalid");
  console.log("Form valid, submitting:", email.value);
});

Check: In a submit handler, why should you prevent the default before validating, and what should happen if validation fails?

Section 42 — Validating on input for instant feedback

Validating on input means checking a field's value as the user types, and giving feedback immediately, rather than waiting until submission, which can help users correct mistakes early, before they have filled the rest of the form and forgotten the field. Instant feedback is most useful for format rules that are clear even for partial input, such as a password strength meter that updates as the user types, showing weak, medium, or strong based on length and character variety, or a username availability check that debounces and queries as the user types, though the latter needs debouncing to avoid excessive requests, as Section 29 explained. For those cases, live feedback feels responsive and helpful, because the user sees the result of their typing without needing to submit or to leave the field, and they can adjust their input in real time, guided by the feedback, rather than submitting, failing, and correcting in a separate step, which is a longer, more frustrating cycle.

Live validation must be designed carefully, because feedback that appears too early or too harshly can be annoying and counterproductive. A field that shows "Invalid email" while the user is still typing the first few characters of an email address is technically correct — the partial address is not yet a valid email — but it is unhelpful, because the user has not finished typing, and the error will disappear as they continue, creating a flicker of false errors that trains users to ignore feedback. The remedy is to delay live validation until the field has been touched and the user has paused, or until the value reaches a minimum length, or to show only positive feedback live, such as a strength meter, and reserve negative feedback for blur or submit, where the user has finished the field. That distinction — positive live, negative on completion — is a good guideline: encourage progress live, but do not punish incomplete input live, because incomplete is the normal state during typing, not an error state.

Live validation and submit validation should work together, not replace each other, because live validation cannot cover fields the user never interacts with, and it cannot cover cross-field rules that depend on multiple fields, some of which may not yet be filled when live validation runs. The submit handler remains the comprehensive check, as Section 41 described, and live validation is an enhancement that helps users reach a valid submit state sooner, by catching format errors early for fields they do interact with. When live validation and submit validation disagree — for example, live validation says a field is valid, but submit validation says it is not, because a cross-field rule fails — the submit validation should win, because it has the full picture, and the live feedback should be updated to reflect the submit result, so the user is not left with conflicting messages.

const password = document.querySelector("#password");
const meter = document.querySelector("#strength");
password.addEventListener("input", function () {
  const value = password.value;
  if (value.length === 0) {
    meter.textContent = "";
  } else if (value.length < 8) {
    meter.textContent = "Weak — use at least 8 characters";
  } else {
    meter.textContent = "Good length";
  }
});

Check: Why should live validation avoid showing an error for a partially typed email address?

Section 43 — Accessibility: keyboard, not just mouse

Accessibility for events means ensuring that every mouse interaction has a keyboard equivalent, because users who cannot use a mouse — due to disability, injury, or device — navigate and activate features with a keyboard, with Tab to move focus, Enter and Space to activate, and Escape to close or cancel. A feature that only works with a mouse click, such as a div with a click handler that does not respond to keyboard activation, is inaccessible, even if it looks and works perfectly for mouse users, because keyboard users cannot reach it, cannot activate it, and may not even know it exists, if it is not focusable and not announced as interactive. The fix, in most cases, is to use a native interactive element — a button for an action, a link for navigation, an input for data entry — because native elements come with keyboard behaviour, focusability, and screen-reader roles built in, tested, and consistent across browsers, while custom elements require you to replicate all of that behaviour yourself, with more code and more chances for gaps, as Section 5 noted for click activation.

Keyboard accessibility also means that focus order should be logical, following the visual order and the reading order of the page, so Tab moves through interactive elements in an order that makes sense, rather than jumping randomly due to DOM order differing from visual order, or due to positive tabindex values that reorder tabbing in confusing ways. Positive tabindex values, like tabindex="1", tabindex="2", should generally be avoided, because they override the natural DOM order and create a tab order that is hard to maintain and easy to break when elements are added or removed, while tabindex="0" puts an element in the natural order, and tabindex="-1" makes it focusable programmatically but not via Tab, which are the two values that should be used, for the cases covered in Chapter 19.

Screen-reader accessibility for events goes beyond keyboard activation, to announcements and roles. An interactive element should have a role that describes what it is — button, link, tab, menu item — which native elements provide automatically, and a name that describes what it does, from its text content or its aria-label, so a screen-reader user hearing a list of controls knows what each one does, without seeing its visual context. A button whose text is only an icon, with no text alternative, will be announced as "button" with no name, which is useless, and the fix is to add text, visually hidden if necessary, or an aria-label that names the action, such as "Delete task". Those naming and state practices, combined with keyboard activation, make event features accessible in the full sense, not just keyboard-reachable.

// Accessible: a real button, keyboard-activatable, with a clear name
const button = document.querySelector("button");
button.addEventListener("click", function () {
  console.log("Button activated by mouse or keyboard");
});
// Inaccessible pattern to avoid: a div with a click handler, not focusable
// <div onclick="doSomething()">Click me</div> — no keyboard access

Check: Why is a real button more accessible than a div with a click handler?

Section 44 — Focus visible and keyboard activation

Focus visible means that the currently focused element is clearly indicated, usually with an outline or a ring, so keyboard users can see where they are on the page as they Tab through it. Browsers provide a default focus outline, which should not be removed with CSS, such as outline: none, unless it is replaced with an equally clear custom indicator, because removing it without a replacement leaves keyboard users blind to their position, tabbing through a page with no visible cue for which element is focused, which makes keyboard navigation unusable in practice, even if it is technically possible. The CSS :focus-visible pseudo-class helps here, because it applies styles only when focus should be visibly indicated, typically for keyboard focus, but not for mouse clicks, where an outline might be considered visually noisy, allowing a design that shows a clear outline for keyboard users without showing it for every mouse click.

Keyboard activation means that interactive elements respond to Enter and Space in the expected way: buttons activate on both Enter and Space, with Space activating on keyup rather than keydown, to allow the user to cancel by moving focus away before releasing, while links activate on Enter only, not on Space, because Space scrolls the page when a link is focused, which is the browser's standard behaviour for links. Native elements implement those distinctions automatically, which is another reason to prefer them, while custom controls must implement them manually, including the Space-on-keyup detail, which is easy to get wrong by activating on keydown, causing an action to run even when the user intended to cancel by moving away.

Focus management after activation is the third part of this topic, because activating a control often changes the page, and focus should end up in a sensible place after that change, as Chapter 19's focus sections discussed. Activating a button that opens a panel should move focus into the panel or to its heading, activating a button that deletes an item should move focus to a neighbouring item or to the list, and activating a button that closes a dialog should return focus to the button that opened it, so keyboard users are not left without a position or stranded in a removed element. Those focus moves should be implemented in the same handler that performs the action, as part of the action's completion, not as a separate afterthought, because the handler is the place where the page change is known, and it is therefore the place where the focus consequence can be handled correctly. A handler that performs an action but leaves focus in a removed or hidden element, or in a place unrelated to the action, creates a keyboard experience that feels broken, even though the action itself succeeded, because the user's location no longer matches the page's new state.

/* Keep focus visible: custom style, not removal */
button:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}
const button = document.querySelector("button");
button.addEventListener("click", function () {
  console.log("Activated by mouse, Enter, or Space — native button handles all");
});

Check: Why should you never remove the focus outline without providing a replacement?

Section 45 — Async work in handlers

Async work in handlers is work that takes time and completes later, such as fetching data from a server, waiting for a timer, or reading a file. It is common in handlers that save, search, or load data on user action. A handler that starts async work should give feedback immediately, such as disabling a button or showing a loading indicator, because the user needs to know their action was received, rather than wondering whether the click did anything while waiting for the result. This is the same feedback principle as Section 34's double-submit prevention, applied to all async actions. The handler should also handle both success and failure, with .then() and .catch() for promises, or try and catch for async functions, because network requests can fail. A handler that only handles success will leave the user with a loading indicator forever and no error message when the request fails, which looks stuck rather than broken.

An async handler can use await inside it to wait for a promise, which makes async code read like synchronous code, in sequence, rather than as nested callbacks. It is the modern style for handlers that do async work. For example, an async click handler might await a fetch, then update the display, then re-enable the button, with a try block around the await for error handling, and a finally block for cleanup that should happen whether the work succeeded or failed, such as re-enabling the button and hiding the loading indicator. Cleanup in a finally block runs on both success and failure, preventing the stuck-loading bug, while cleanup only in the success path would be skipped on failure. A handler that is async but does not handle errors, or does not clean up in a finally, is incomplete, even if its success path works, because real async work fails often enough that the failure path will be exercised by real users.

Race conditions are a specific async-handler problem: when a user triggers the same async action rapidly, such as typing a search that fetches on each debounced input, responses can arrive out of order, so an older, slower response overwrites a newer, faster one, showing stale data for the latest query. The fix is to track the latest request, for example with a counter or an AbortController that cancels previous requests when a new one starts, and to ignore responses that are not for the latest request, so only the most recent query's results are displayed. That latest-wins discipline is essential for search and for any feature where overlapping async work is allowed, because without it the display can flicker or settle on old results, even though each request succeeded. For simple handlers where overlapping triggers are prevented by disabling the button, as in Section 34, race conditions are less likely, because only one request can be in flight at a time.

const button = document.querySelector("#load");
button.addEventListener("click", async function () {
  button.disabled = true;
  button.textContent = "Loading...";
  try {
    const response = await fetch("https://example.com/data.json");
    const data = await response.json();
    console.log("Loaded:", data);
  } catch (error) {
    console.log("Load failed:", error.message);
  } finally {
    button.disabled = false;
    button.textContent = "Load";
  }
});

Check: Why should async handler cleanup, like re-enabling a button, go in a finally block?

Section 46 — Errors in handlers

Errors in handlers are errors thrown by handler code during an event. An error in one listener does not prevent other listeners for the same event from running — the browser will continue dispatching to them — but it does stop the failing handler at the point of the error, so any code after the error in that handler will not run, and it will log an error to the console. That stopping behaviour means a handler that throws halfway through, after updating data but before re-rendering, will leave the page in a half-updated state, with data changed but display stale. The user sees no change despite an action that partially succeeded, and the state is hard to recover without reloading, because display and data are now out of step. The remedy is to write handlers defensively, checking for null, missing data, and invalid values before acting, and to structure handlers so a failure does not leave a half-completed update — for example, by validating before updating, rather than updating and then validating, the safer order for the same reason as submit validation before submission.

For errors that cannot be prevented by checks, such as network failures or unexpected data shapes from a server, handlers should catch them explicitly, with try and catch for synchronous and async code, and with .catch() for promise chains, and should show a user-facing error message, rather than leaving the failure silent in the console, where only developers will see it. A user-facing error should say what went wrong in plain language, and what the user can do next, such as "Could not save. Check your connection and try again", rather than showing a technical message or stack trace, which most users cannot act on. It should also preserve the user's input, so they do not lose work when an error occurs — especially important for forms, where losing a long input due to a save error is a serious usability failure.

Global error handling, through window.addEventListener("error", ...) or window.onerror, can catch handler errors that were not caught locally, and log them for monitoring. That is useful in production, where console errors are not visible to developers, and knowing errors are happening, and how often, is the first step to fixing them. However, global handling should not replace local handling, because by the time a global handler runs, the failing handler has already stopped halfway, and the page may already be in a half-updated state that a global handler cannot easily repair, since it does not know what the failing handler was trying to do. Global handling is therefore a safety net for monitoring and for showing a generic fallback message, not a recovery mechanism for specific features. Each feature's handler remains responsible for its own errors, feedback, and state consistency: local handling for recovery, global handling for monitoring.

const button = document.querySelector("button");
button.addEventListener("click", function () {
  try {
    const value = document.querySelector("#input").value;
    if (!value) {
      throw new Error("Input is empty");
    }
    console.log("Processing:", value);
  } catch (error) {
    console.log("Handler error:", error.message);
    document.querySelector("#error").textContent = "Please enter a value and try again";
  }
});

Check: Why does an error halfway through a handler leave the page in a confusing state, and how do you prevent it?

Section 47 — Order of listeners and timing

Listeners run in the order they were added for the same event type on the same element, and that order is predictable, but relying on it for correctness, as Section 20 warned, creates hidden coupling between listeners that should be independent. Timing also matters more broadly: a listener added after an event has already occurred will not run for that past event, because events are not queued for future listeners — they are dispatched to listeners that exist at the moment of the event, and a listener added later misses earlier events entirely. That has practical consequences for setup: listeners must be attached before the user can trigger the event, usually during initialisation, after the DOM is ready, as Chapter 19 explained, and before interaction. Testing setup timing means interacting with a page as soon as it appears, to see whether early clicks are handled. In practice this race is rare, except for features that attach listeners after an async load, where a user could interact before async setup completes. Such a feature should either disable its controls until setup is done, or queue early actions, rather than silently dropping them.

Another timing issue is the order of events themselves, when a single user action generates multiple events in sequence, such as a click generating mousedown, mouseup, and click, or a key press generating keydown and keyup, or a form submission generating input, change, and submit in various orders depending on the control. Handlers for different events on the same element should be written with that sequence in mind, so they do not interfere — for example, a mousedown handler that changes the element in a way that affects the subsequent click handler's target, or an input handler that validates a value that a change handler then re-validates differently, causing conflicting messages. The safest approach is to handle a feature at a single event level, where possible, rather than splitting it across multiple event types that fire for the same action, because a single-level handler has a single, clear trigger, while a multi-level handler must reason about the sequence and which handler runs first.

Timing also appears in event loop ordering: a handler that schedules work with setTimeout() or with a promise will have that work run after the current event dispatch completes and after other pending handlers have run. That can affect the order in which updates appear, especially when a handler updates the display immediately and also schedules an async update that will overwrite it later. Understanding that scheduled work runs later, not immediately, helps you decide whether to show a loading state immediately, to avoid showing stale data while waiting, or to wait for the async result before updating at all, for features where an intermediate update would be misleading. For most handlers, the timing rule to remember is simple: synchronous code in the handler runs during the event, scheduled code runs after it, and listeners for the same event run in registration order during it.

const button = document.querySelector("button");
button.addEventListener("click", function () {
  console.log("First listener");
});
button.addEventListener("click", function () {
  console.log("Second listener");
  setTimeout(function () {
    console.log("Scheduled work runs after both listeners");
  }, 0);
});
button.addEventListener("click", function () {
  console.log("Third listener");
});

Check: If a listener is added after an event has already happened, will it run for that event?

Section 48 — Removing listeners and cleaning up

Cleaning up listeners means removing them when they are no longer needed, so they do not keep running, keep references alive, or cause duplicate handling after a feature is disabled, a component is removed, or a temporary interaction ends. The basic removal tool is removeEventListener(), covered in Section 21, which requires the exact function reference. The cleanup pattern is to pair every temporary addEventListener() with a corresponding removal at the end of the temporary period, such as removing document mousemove listeners when a drag ends, removing a document click listener when a panel closes, as Section 33 showed, or removing a key listener when a dialog closes, so temporary behaviour does not persist beyond its intended lifetime. That is how leaked listeners accumulate, with handlers running long after their feature was supposed to end, causing surprising behaviour that is hard to attribute to a listener that should have been removed.

For listeners on elements that are removed from the page, explicit removal is usually unnecessary, because garbage collection will reclaim the element and its listeners once no references remain. But for listeners on long-lived elements like document, window, or a stable container, explicit removal is essential, because those elements are never removed, so their listeners will run forever unless removed, and they will keep alive any data their closures capture. That can become a memory leak in a long-running single-page application where features are repeatedly created and destroyed. The leak is gradual, not catastrophic: each leaked listener adds a small amount of retained memory and work on each event, but over many feature creations, the accumulation can slow the page noticeably. That is why single-page applications pay close attention to cleanup, often through component lifecycle hooks that automatically remove listeners when a component unmounts — a pattern plain JavaScript code must implement manually, by tracking listeners and removing them in a cleanup step. For the small pages in this book, manual cleanup for temporary listeners on document and window is sufficient, and it is a good habit to build.

A related cleanup tool is the AbortController and AbortSignal API, where an AbortSignal passed as the signal option to addEventListener() allows all listeners sharing that signal to be removed at once by calling abort() on the controller, without needing to keep each function reference separately. The signal approach is modern and supported in current browsers, and it is worth knowing about, even if you do not use it for every listener, because it solves the "forgot one removal" problem by grouping listeners by lifetime and removing the group as a unit. Whether you use individual removal or signal-based bulk removal, the principle is the same: listeners have a lifetime, and that lifetime should be managed deliberately, with a clear end, rather than left to run indefinitely by default.

const controller = new AbortController();
document.addEventListener("mousemove", function () {
  console.log("Dragging...");
}, { signal: controller.signal });
document.addEventListener("mouseup", function () {
  console.log("Drag ended, cleaning up");
  controller.abort(); // removes all listeners sharing this signal
}, { once: true });

Check: Why is cleanup especially important for listeners on document and window, compared to listeners on elements that are removed?

Section 49 — State, render, listen: the full loop

The full loop of an interactive page is: state, render, listen, in a cycle. State is the data, held in variables, such as an array of tasks, a current filter, or a user's settings. Render is the function that turns state into display, by clearing a container and building elements from the state, as Chapter 19's render pattern described. Listen is the set of event handlers that, when the user acts, update the state and then call render again, so the display follows the new state, and the cycle repeats for the next action.

Designing a feature with the loop in mind starts with state design, which is the data modelling skill from Chapter 18, applied to interactive data. A task list's state is an array of task objects, each with a text and a completed flag. A filter's state is a string naming the current filter, such as "all", "active", or "completed". A counter's state is a single number. Those state definitions are simple, but they are the foundation, because every display element and every handler can be derived from them: the list display is a render of the tasks array filtered by the current filter, the count display is a render of the tasks array's length, and the add handler pushes to the tasks array and re-renders, while the filter handler sets the filter string and re-renders. That derivation — display as a function of state, handlers as state updates plus re-render — is what makes the loop predictable, because there is only one source of truth, the state, and the display is always a faithful view of it, with no independent display state that could drift, except for transient UI state like focus or scroll position, which are legitimately display-only and should not be confused with application state.

The loop also clarifies debugging, because a bug can be localised to one of its three parts: is the state wrong, is the render wrong, or is the handler wrong? If the display is wrong, check the state first, by logging it: if the state is correct but the display is wrong, the render function is at fault; if the state is wrong, the handler that updated it is at fault, because it updated incorrectly or did not update at all, or it updated but did not re-render, leaving the display stale despite correct state. That three-way diagnostic — state, render, handler — is faster than guessing, because it narrows the search to one part, and it works for any feature built with the loop, regardless of its complexity, because the loop's structure is the same in every case.

let tasks = ["Buy milk"];
function render() {
  const list = document.querySelector("ul");
  list.replaceChildren();
  for (const task of tasks) {
    const li = document.createElement("li");
    li.textContent = task;
    list.appendChild(li);
  }
  document.querySelector("#count").textContent = tasks.length + " tasks";
}
document.querySelector("#add").addEventListener("click", function () {
  const input = document.querySelector("#task-input");
  const text = input.value.trim();
  if (!text) {
    return;
  }
  tasks.push(text); // update state
  input.value = "";
  render(); // render from new state
});
render(); // initial render from initial state

Check: In the state-render-listen loop, what should a handler do after updating state, and why?

Section 50 — Chapter 20 in one paragraph

Events are the browser's announcements that something happened, and event handling is listening for the announcements you care about and responding by updating state and re-rendering the display, which is the loop that makes pages interactive. Mastering those tools — the event object, bubbling and delegation, prevention and stopping, and the state-render-listen loop — gives you the ability to build any interactive feature, from a simple button that adds an item to a complex form with validation, search with debouncing, and a panel with click-outside and Escape handling, because each of those features is a variation on the same loop, with different events, different state, and different renders, but the same underlying structure.

The habits that make event code reliable are worth restating as a closing checklist, because they are the difference between code that works in a demo and code that works for real users, with different input methods, different timing, and different error conditions. Use the right event for the intention, timing, and scope, and test with both mouse and keyboard. Use native interactive elements, keep focus visible, and provide keyboard equivalents for mouse features, so events are accessible, not just functional. Guard against null, validate before updating, and handle errors with user-facing messages and preserved input, so handlers fail gracefully rather than half-completing. Prevent double submissions with flags, disabling, and feedback, and clean up temporary listeners, so features do not leak behaviour beyond their lifetime. And keep the state-render-listen loop intact, by updating state first and re-rendering second, never editing display directly in a way that state cannot reproduce, so the display always faithfully reflects the data, no matter how many actions the user performs. Those habits, applied consistently, turn event handling from a collection of tricks into a disciplined practice, which is what professional front-end work requires.

Looking ahead, the skills in this chapter and the previous two chapters — data in arrays and objects, display through the DOM, and interaction through events — together form the core of front-end development in plain JavaScript, without any framework, and they are sufficient to build real, interactive pages, from task lists to product browsers to forms with validation, which is a significant achievement and a solid foundation for whatever comes next, whether that is fetching data from servers, using a framework that automates rendering and event handling, or building larger applications where the same state-render-listen loop appears at a larger scale, with more state, more renders, and more events, but the same principles. Before moving on, test yourself on the chapter's core operations without looking back: attach a click listener correctly, without calling the handler immediately; explain bubbling and delegation, and why delegation works for dynamic elements; prevent a form's default submission and validate it; debounce a search input; and describe the state-render-listen loop in your own words, with an example.

// The loop in one small example:
let count = 0;
const display = document.querySelector("#count");
document.querySelector("#increment").addEventListener("click", function () {
  count += 1; // update state
  display.textContent = count; // render from state
});

Check: In your own words, what are the three parts of the state-render-listen loop, and what does each one do?

Practical task

Build an interactive "task manager" page that uses the full state-render-listen loop: keep tasks in a JavaScript array of objects, each with text and a completed flag, and write a render function that clears a list and rebuilds it from the array, showing a count of remaining tasks. Add a form (not just a button) for adding tasks, handling its submit event with preventDefault(), ignoring empty input, and re-rendering. Use event delegation on the list so that clicking a task toggles its completed state and clicking a delete button inside a task removes it, both updating state and re-rendering, including for tasks added after the page loads. Add a search input with a debounced input handler that filters the displayed tasks without changing the underlying array, and test the whole page with mouse and keyboard, including submitting with Enter.

Chapter questions

  1. Explain bubbling and event delegation, and say why delegation works for elements added after the page loads.
  2. Write a submit handler for a form that prevents the default submission, validates that a name field is not empty, shows an error and focuses the field if it is empty, and logs the name if it is valid.
  3. What is the difference between preventDefault() and stopPropagation(), and when would you need each one?
  4. A search input triggers a network search on every keystroke and feels slow. How would debouncing fix it, and what interval would you start with?
  5. Describe the state-render-listen loop, and explain what goes wrong if a handler edits the display directly without updating state.