JavaScript · Intermediate
Events in JavaScript
In short: An event is a signal that something happened: a click, a key press, a form submission. JavaScript reacts by registering a listener with target.addEventListener(type, handler); when the event is dispatched the handler receives an event object describing it. In a page, events bubble up from the element where they started through its ancestors, so one listener on a parent can handle many children.
Listening instead of polling
A program in a page cannot sit in a loop waiting for a click; it would freeze the browser. Instead it registers interest and returns control. button.addEventListener("click", handler) tells the browser to call handler every time that button is clicked, and the code goes back to doing nothing until then. The same model runs the whole platform: keyboard input, scrolling, network responses, timers, media playback and messages from other windows are all delivered as events to whatever registered a listener.
The pieces are an event target, a type and a listener. DOM elements, document and window are targets, and so is any object created from EventTarget, which browsers and Node.js both provide; the runnable examples below use it because it works without a page. A target can hold many listeners for the same type, called in the order they were added, and a listener can be added for any type name, including names you invent for your own components. removeEventListener needs the same type and the same function object that was added, which is why anonymous arrow functions cannot be removed; the { once: true } option removes a listener automatically after its first call.
Every listener receives an event object. event.type is the name, event.target is the object where the event started, and event.currentTarget is the object whose listener is running now; the two differ during bubbling. Specific events add their own fields: key for keyboard events, clientX for mouse events. Some events have a default action, such as a link navigating or a form submitting and reloading the page; event.preventDefault() cancels it, provided the event was created as cancelable, and dispatchEvent returns false when a listener cancelled the event. Custom events carry your own data: new CustomEvent("add", { detail: { name, price } }) puts an arbitrary value in event.detail.
In a page, an event dispatched on an element travels. First the capture phase runs listeners from window down to the target's parent, then listeners on the target itself, then the bubble phase runs listeners from the parent back up to window. Almost all code uses the bubble phase, which is the default. event.stopPropagation() ends the journey after the current element's listeners finish; stopImmediatePropagation() also skips the remaining listeners on the current element.
Bubbling enables event delegation: instead of adding a listener to each of a hundred list items, add one to the list and inspect event.target (often with target.closest("li")) to find out which item was clicked. Items added later are handled automatically, and there is one listener to remove instead of a hundred. The last example models bubbling with a small tree of plain objects, because the DOM's parent chain is what the real mechanism walks.
Handlers run later, on the browser's event loop, after the code that registered them has finished. A try/catch around addEventListener therefore does not catch errors thrown inside the handler, and variables the handler needs must be captured by closure or read from the event. The DOM basics lesson covers the elements these events are attached to; promises covers how the same event loop delivers asynchronous results.
Syntax
target.addEventListener("click", handler); // register
target.addEventListener("click", handler, { once: true }); // auto-remove after first call
target.removeEventListener("click", handler); // same function object required
function handler(event) {
event.type; // "click"
event.target; // where the event started
event.currentTarget; // the element this listener is on
event.preventDefault(); // cancel the default action (navigation, submit)
event.stopPropagation(); // stop bubbling to ancestors
}
// your own events
const bus = new EventTarget();
bus.dispatchEvent(new CustomEvent("saved", { detail: { id: 7 } }));
// in a page: delegation and readiness
document.querySelector("ul").addEventListener("click", (e) => {
const li = e.target.closest("li");
if (li) console.log(li.textContent);
});
document.addEventListener("DOMContentLoaded", start);Pass the function itself, handler, not a call, handler(). The third argument may also be true to listen in the capture phase.
Adding, removing and once-only listeners
An EventTarget stands in for a button. Three listeners are attached to the press event, one of them once-only; then one is removed and the event is dispatched again.
const doorbell = new EventTarget();
function ring(event) {
console.log(`ring: type=${event.type}, target is doorbell: ${event.target === doorbell}`);
}
doorbell.addEventListener("press", ring);
doorbell.addEventListener("press", () => console.log("second listener also runs"));
doorbell.addEventListener("press", () => console.log("runs only once"), { once: true });
doorbell.dispatchEvent(new Event("press"));
console.log("--- removing ring, pressing again");
doorbell.removeEventListener("press", ring);
doorbell.dispatchEvent(new Event("press"));
doorbell.dispatchEvent(new Event("release"));
console.log("done");Output
ring: type=press, target is doorbell: true second listener also runs runs only once --- removing ring, pressing again second listener also runs done
The first dispatch calls all three listeners in the order they were added. ring could be removed because the same function object was passed to both calls; the arrow function in the second listener could not be, since no variable holds it. The once-only listener removed itself. Dispatching release prints nothing because no listener was registered for that type, and nothing is thrown either. This is real EventTarget code that runs unchanged in a browser page, with a DOM element in place of doorbell.
Custom events with data, and cancelling a default
A cart object emits add events carrying item details, and a checkout event that a listener can cancel. dispatchEvent reports whether the event was cancelled.
const cart = new EventTarget();
let total = 0;
cart.addEventListener("add", (event) => {
const { name, price } = event.detail;
total += price;
console.log(`added ${name}, total ${total}`);
});
cart.addEventListener("checkout", (event) => {
if (total > 50) {
console.log("over budget, blocking checkout");
event.preventDefault();
}
});
cart.dispatchEvent(new CustomEvent("add", { detail: { name: "kettle", price: 32 } }));
cart.dispatchEvent(new CustomEvent("add", { detail: { name: "mugs", price: 24 } }));
const allowed = cart.dispatchEvent(new Event("checkout", { cancelable: true }));
console.log("checkout allowed:", allowed);Output
added kettle, total 32 added mugs, total 56 over budget, blocking checkout checkout allowed: false
detail is the standard slot for data on a CustomEvent; the listener destructures it. The checkout event is created with cancelable: true, so preventDefault() has an effect and dispatchEvent returns false to tell the dispatching code not to proceed. A form's submit event works the same way in a page: the browser dispatches it, a listener calls preventDefault(), and the browser skips the page reload.
Bubbling and delegation, modelled with a small tree
Three nodes form a chain: a button inside a row inside a form. dispatch runs the listeners of the target, then of each ancestor, unless a listener stops propagation.
function node(name, parent) {
const n = { name, parent, listeners: [] };
n.on = (type, fn) => n.listeners.push({ type, fn });
return n;
}
const form = node("form", null);
const row = node("row", form);
const button = node("button", row);
function dispatch(target, type) {
const event = { type, target, stopped: false, stopPropagation() { this.stopped = true; } };
let current = target;
while (current !== null && !event.stopped) {
for (const listener of current.listeners) {
if (listener.type === type) listener.fn(event, current);
}
current = current.parent;
}
}
form.on("click", (e, cur) => console.log(`${cur.name} saw a click that started at ${e.target.name}`));
row.on("click", (e, cur) => console.log(`${cur.name} saw a click that started at ${e.target.name}`));
button.on("click", (e, cur) => console.log(`${cur.name} saw a click that started at ${e.target.name}`));
dispatch(button, "click");
console.log("--- row now stops propagation");
row.on("click", (e) => e.stopPropagation());
dispatch(button, "click");
console.log("--- click on the row itself");
dispatch(row, "click");Output
button saw a click that started at button row saw a click that started at button form saw a click that started at button --- row now stops propagation button saw a click that started at button row saw a click that started at button --- click on the row itself row saw a click that started at row
The cur argument plays the role of event.currentTarget and e.target stays the button all the way up: that is what lets the form's single listener know which button was pressed, which is delegation. After stopPropagation is added to the row, the walk ends once the row's listeners have run, so the form never hears the second click. In a browser the walk is the DOM parent chain and dispatch is what the browser does for you.
Everyday DOM events
| Event | Fires when | Useful fields or notes |
|---|---|---|
| click | an element is clicked or activated by keyboard | target; bubbles |
| input | a field's value changes, on every keystroke | target.value |
| change | a field's value is committed (blur, select) | target.value, target.checked |
| submit | a form is submitted | preventDefault() to stop the reload |
| keydown | a key is pressed | key ("Enter", "Escape") |
| DOMContentLoaded | the HTML has been parsed | on document; safe point to query elements |
| load | the page and all resources have finished loading | on window |
Common mistakes
Calling the handler instead of passing it
Why it goes wrong:
button.addEventListener("click", save())runssaveimmediately, once, and registers its return value (usuallyundefined) as the listener. Nothing happens on later clicks.Fix: Pass the function itself, or wrap a call that needs arguments in an arrow function.
JavaScript · fixbutton.addEventListener("click", save); button.addEventListener("click", () => save(draftId));Trying to remove an anonymous listener
Why it goes wrong:
removeEventListener("click", () => {...})passes a new function that was never added, so nothing is removed and the original keeps running.Fix: Store the function in a variable and use it for both calls, or use
{ once: true }or anAbortControllersignal for removal.Forgetting preventDefault on a form submit
Why it goes wrong: The browser's default action for
submitis to send the form and load the response, which discards the page and any JavaScript state.Fix: Call
event.preventDefault()at the start of the submit handler when the page handles the data itself.Assuming the handler's errors are caught by surrounding code
Why it goes wrong: The handler runs later, from the event loop, long after the
tryblock that registered it has finished. An error thrown inside it becomes an uncaught error reported by the browser.Fix: Put the
try/catchinside the handler where the risky work is.
Where you use this
Any interactive control is an event listener: a search box that filters a list on input, a form that validates on submit, a modal that closes on the Escape key, a menu that toggles on click. Delegation is the practical pattern for lists and tables: one listener on the container reads event.target.closest("tr") to learn which row was clicked, so rows can be added or removed without touching listeners.
Custom events decouple parts of an application. A shopping cart module can dispatch "cart:changed" with the new total in detail, and a header badge, a checkout button and an analytics hook can each listen without the cart knowing they exist. The same EventTarget API works in Node.js, so the pattern is not limited to pages.
list.addEventListener("click", (event) => {
const row = event.target.closest("tr");
if (!row) return;
select(row.dataset.id);
});Key points
addEventListener(type, handler, options)registers;removeEventListenerneeds the same function object;{ once: true }self-removes.- The handler receives an event object with
type,target,currentTargetand event-specific fields. preventDefault()cancels a cancelable default action;dispatchEventreturnsfalsewhen that happened.CustomEventwithdetailcarries your own data;EventTargetworks in browsers and Node.js.- DOM events bubble from the target up through its ancestors;
stopPropagation()ends the climb. - Delegation puts one listener on a parent and uses
event.targetto find the child. - Handlers run later from the event loop, so surrounding
try/catchdoes not cover them.
Try it yourself
Register a listener for the tick event on clock that increments ticks and prints tick followed by the new count. The loop dispatches the event three times, so the program should print tick 1, tick 2, tick 3 and then the total.
const clock = new EventTarget();
let ticks = 0;
// add a "tick" listener that increments ticks and prints "tick <n>"
for (let i = 0; i < 3; i++) {
clock.dispatchEvent(new Event("tick"));
}
console.log("total ticks:", ticks);tick 1
tick 2
tick 3
total ticks: 3const clock = new EventTarget();
let ticks = 0;
clock.addEventListener("tick", () => {
ticks += 1;
console.log("tick " + ticks);
});
for (let i = 0; i < 3; i++) {
clock.dispatchEvent(new Event("tick"));
}
console.log("total ticks:", ticks);Practise this
Exercises for this lesson are in the JavaScript practice set.
Open the JavaScript playground
Related lessons
- DOM Basics in JavaScriptHow JavaScript reads and changes a web page through the DOM: querySelector, textContent and innerHTML, classList, createElement and append, and script timing.11 min
- Closures in JavaScriptWhat a JavaScript closure is, how a function keeps access to the scope it was created in, and how to use that for counters, factories and callbacks.10 min
- JavaScript Functions: Declarations, Arrows and Return ValuesHow to define and call JavaScript functions with declarations, expressions and arrow functions; parameters, default values, return and local scope.10 min
Frequently asked questions
What is the difference between event.target and event.currentTarget?
target is the element where the event originated, for example the button that was actually clicked. currentTarget is the element whose listener is running right now, which during bubbling may be an ancestor such as the form. Inside a delegated listener on a container, currentTarget is the container and target tells you which child was hit.
What is event bubbling?
After an event fires on an element, the browser dispatches the same event on that element's parent, then the grandparent, and so on up to document and window. Listeners on each of those run in turn unless one calls stopPropagation(). It exists so that a single listener high in the tree can respond to events from many descendants, which is the basis of event delegation. Most events bubble; a few such as focus and load do not.
Should I use onclick or addEventListener?
Prefer addEventListener. An onclick property holds a single handler, so assigning a second one replaces the first, and inline onclick="..." attributes mix code into markup and cannot see functions inside modules. addEventListener allows any number of listeners, supports options such as once and capture, and pairs with removeEventListener.
How this page was checked. Every program on it was run with Node.js 22 at build time; runs in your browser in an isolated Web Worker by the publishing checks, and the output shown is what it printed. The Run buttons execute the same code on your device; nothing you type is sent anywhere.