addEventListener is the door between your JavaScript and everything a person can do to a page — click, type, submit, scroll, press a key. The method itself takes one line. What takes longer is understanding the event object it hands you, where that event came from, and how to stop it doing what you did not want. This page decodes all three.
What are the three arguments addEventListener takes?
The signature is addEventListener(type, listener, options). The type is the event name as a string, such as "click", "submit", "input" or "keydown". The listener is a function that runs when the event fires. The options argument is optional and controls how the browser handles the event; leaving it out is the same as passing an empty object.
Inside the listener you receive an event object. You do not need it in order to handle an event, but it is where the useful detail lives: event.type names the event, event.target is the element the event struck, event.currentTarget is the element your listener is attached to, and the preventDefault and stopPropagation methods control what happens next.
<button id="intro-btn">Save</button>
<form id="search">
<input id="q" name="q" type="search" placeholder="Search">
<button type="submit">Go</button>
</form>
<div id="log" class="log" aria-live="polite"></div>
<ul id="menu">
<li class="item"><a href="#one">One</a></li>
<li class="item"><a href="#two">Two</a></li>
</ul>
<ul id="tree">
<li id="branch" class="branch"><a href="#leaf">Leaf</a></li>
</ul>
<ul id="capture-tree">
<li class="cap"><a href="#cap">Cap</a></li>
</ul>
<button id="once-btn">Once</button>
<button id="cleanup-btn">Cleanup</button>
const save = document.querySelector("#intro-btn");
save.addEventListener("click", (event) => {
console.log(event.type); // "click"
console.log(event.currentTarget.id); // "intro-btn" — the element the listener sits on
console.log(event.target === save); // true — the click landed on it directly
});
save.dispatchEvent(new MouseEvent("click"));
console.log("after dispatch");
Notice the ordering in the output: the handler's lines appear before after dispatch, because the listener runs synchronously during dispatchEvent. That is worth remembering when you test event code — nothing is queued, everything happens immediately.
Why is event.target not the element I attached the listener to?
Because two different questions are being answered. event.target answers "what was actually clicked?". event.currentTarget answers "which element is this listener registered on?". When you listen on a container, the target is the innermost element involved in the interaction and the current target is your container.
That distinction is the single most useful thing on this page, because it tells you how to find the interesting element. If you listen on a list and want to know which entry was clicked, reach up from the target with closest rather than assuming the target is the entry.
const menu = document.querySelector("#menu");
menu.addEventListener("click", (event) => {
const link = event.target.closest("a");
console.log("target:", event.target.tagName); // "A"
console.log("currentTarget:", event.currentTarget.id); // "menu"
console.log("link:", link?.textContent); // "One"
});
menu.querySelector("a").dispatchEvent(new MouseEvent("click", { bubbles: true }));
How does an event travel from the element up to the document?
Most events bubble. The full journey has three phases: the capture phase travels down from the window to the target, the target phase fires on the target itself, and the bubble phase travels back up from the target to the window. Listeners you register without options fire in the bubble phase, which is why a click on a link inside a list reaches the list, then the body, then the document.
Two listeners on the same element run in the order they were registered. This is why the order your addEventListener calls appear in the source is the order the handlers run.
const record = (label) => (event) => {
console.log(label, "->", event.currentTarget.id || event.currentTarget.nodeName);
};
const tree = document.querySelector("#tree");
const branch = document.querySelector("#branch");
const link = branch.querySelector("a");
link.addEventListener("click", record("link"));
branch.addEventListener("click", record("branch"));
tree.addEventListener("click", record("tree"));
const onDocumentClick = record("document");
document.addEventListener("click", onDocumentClick);
link.dispatchEvent(new MouseEvent("click", { bubbles: true }));
// Remove it again — removeEventListener needs the very same function reference
document.removeEventListener("click", onDocumentClick);
The output runs bottom-up: link -> A, branch -> branch, tree -> tree, document -> #document. That upward journey is the mechanism behind event delegation, which is the subject of the next page.
How do you stop the browser's default action?
Preventing the default action and stopping the event are two different operations. event.preventDefault() cancels what the browser would otherwise do — following a link, submitting a form, checking a checkbox — while the event itself still travels on to your other listeners. event.stopPropagation() stops the event travelling, but leaves the default action alone.
Most of the time you want preventDefault. Intercepting a form submit and handling the data yourself is the standard case: without it the browser navigates away and reloads the page, which throws away everything you had built up in JavaScript.
const form = document.querySelector("#search");
document.querySelector("#q").value = "javascript";
form.addEventListener("submit", (event) => {
event.preventDefault();
console.log("searching for:", new FormData(form).get("q"));
console.log("defaultPrevented:", event.defaultPrevented); // true
});
form.dispatchEvent(new Event("submit", { cancelable: true }));
A subtlety worth knowing: preventDefault only works on a cancelable event. Most events are cancelable, but scroll and load are not, and calling preventDefault on them does nothing. After the call, event.defaultPrevented tells you whether it actually took effect.
What do capture, once and passive actually change?
The options argument is an object, and each key tunes one behaviour. capture: true makes the listener fire during the capture phase, on the way down, instead of on the way up. once: true removes the listener automatically after it fires a single time. passive: true promises that you will not call preventDefault, which lets the browser keep scrolling smoothly on touch devices instead of waiting to hear from you.
const onceBtn = document.querySelector("#once-btn");
let clicks = 0;
onceBtn.addEventListener("click", () => {
clicks += 1;
console.log("clicked", clicks);
}, { once: true });
onceBtn.dispatchEvent(new MouseEvent("click"));
onceBtn.dispatchEvent(new MouseEvent("click"));
console.log("total:", clicks); // 1 — the second click never reached the handler
const tree = document.querySelector("#capture-tree");
tree.addEventListener("click", () => console.log("capture on tree"), { capture: true });
tree.querySelector("a").addEventListener("click", () => console.log("bubble on link"));
tree.querySelector("a").dispatchEvent(new MouseEvent("click", { bubbles: true }));
Capture listeners are rare in everyday code, but they are how you intercept an event before it reaches the element that cares about it. The output of the second snippet runs in the opposite order to the previous one: the capture listener on the tree fires first, because the tree is higher up the tree and the capture phase is travelling downwards.
How do you fire your own events?
Not every interesting thing in a page is a user action. A component that loads data, a form that validates, a router that changes the view — these all want to announce that something happened. CustomEvent lets you invent an event type, attach a payload in detail, and dispatch it on any element.
const panel = document.querySelector("#log");
panel.addEventListener("cart:updated", (event) => {
console.log("detail:", event.detail.items);
console.log("type:", event.type);
});
const custom = new CustomEvent("cart:updated", {
detail: { items: 3, total: 42 },
bubbles: true,
});
panel.dispatchEvent(custom);
Setting bubbles: true lets the event travel up the tree like a click would, so a delegated listener higher up the page can catch it. Setting cancelable: true lets listeners call preventDefault to veto the action, which is how a parent component can say "no, do not do that".
How do you remove a listener when you no longer need it?
removeEventListener(type, listener) removes a handler, but it needs the exact same function reference you registered. An inline arrow function cannot be removed, because the reference you would pass is a different function. Keep the handler in a named variable and you can take it off again.
const btn = document.querySelector("#cleanup-btn");
function onSave() {
console.log("saved");
}
btn.addEventListener("click", onSave);
btn.dispatchEvent(new MouseEvent("click"));
btn.removeEventListener("click", onSave);
btn.dispatchEvent(new MouseEvent("click"));
console.log("listener removed");
// An AbortController removes every listener you passed its signal to
const controller = new AbortController();
btn.addEventListener("click", () => console.log("abortable"), { signal: controller.signal });
controller.abort();
btn.dispatchEvent(new MouseEvent("click"));
console.log("after abort");
The signal option is the tidiest way to clean up when several listeners are attached to one element for a lifetime you want to end in one go — closing a dialog, say, or tearing down a widget. Call abort() and all of them detach at once.
The event API reference
addEventListener(type, handler, options)— attach a handler for an event type.event.target— the element the event struck;event.currentTarget— the element your handler is on.event.preventDefault()— cancel the browser's built-in behaviour for this event.event.stopPropagation()— stop the event travelling further up the tree.{ once: true }— fire a single time;{ capture: true }— fire on the way down.new CustomEvent(name, { detail, bubbles, cancelable })— announce your own event.removeEventListener(type, handler)and{ signal: controller.signal }— take handlers off again.
Try it yourself: open a page with a button inside a div inside a list, open the console, and attach one listener to the list that prints event.target.tagName. Click the innermost text and watch it report the deepest element rather than the list. Then add event.stopPropagation() to a listener on the div and confirm the list listener stops firing. Finish by attaching the same named handler twice and removing it once, then attaching it again and removing it a second time.