JavaScript · Intermediate

Events in JavaScript

11 min readUpdated September 24, 2026Every example verified

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

 JavaScript · 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.

 JavaScript
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.

 JavaScript
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.

 JavaScript
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

EventFires whenUseful fields or notes
clickan element is clicked or activated by keyboardtarget; bubbles
inputa field's value changes, on every keystroketarget.value
changea field's value is committed (blur, select)target.value, target.checked
submita form is submittedpreventDefault() to stop the reload
keydowna key is pressedkey ("Enter", "Escape")
DOMContentLoadedthe HTML has been parsedon document; safe point to query elements
loadthe page and all resources have finished loadingon window

Common mistakes

  • Calling the handler instead of passing it

    Why it goes wrong: button.addEventListener("click", save()) runs save immediately, once, and registers its return value (usually undefined) 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 · fix
    button.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 an AbortController signal for removal.

  • Forgetting preventDefault on a form submit

    Why it goes wrong: The browser's default action for submit is 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 try block that registered it has finished. An error thrown inside it becomes an uncaught error reported by the browser.

    Fix: Put the try/catch inside 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.

 JavaScript · in practice
list.addEventListener("click", (event) => {
  const row = event.target.closest("tr");
  if (!row) return;
  select(row.dataset.id);
});

Key points

  • addEventListener(type, handler, options) registers; removeEventListener needs the same function object; { once: true } self-removes.
  • The handler receives an event object with type, target, currentTarget and event-specific fields.
  • preventDefault() cancels a cancelable default action; dispatchEvent returns false when that happened.
  • CustomEvent with detail carries your own data; EventTarget works 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.target to find the child.
  • Handlers run later from the event loop, so surrounding try/catch does 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.

Your program
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);
Expected output: tick 1 tick 2 tick 3 total ticks: 3

Practise this

Exercises for this lesson are in the JavaScript practice set.

Open the JavaScript playground

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.

Progress is stored only in this browser.

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.