In short
JavaScript custom events let you create your own events, like "cart-updated", and send them with dispatchEvent. Learn CustomEvent, the detail property, bubbling and how parts of a page can talk. What are custom events in JavaScript? You already know built-in events like click, input and submit.
JavaScript custom events let you create your own events, like "cart-updated", and send them with dispatchEvent. Learn CustomEvent, the detail property, bubbling and how parts of a page can talk.
What are custom events in JavaScript?
You already know built-in events like click, input and submit. The browser creates and sends those when something happens. Custom events are events that you invent and send yourself, with your own name, like "cart-updated" or "user-logged-in".
Why is this useful? Imagine a shop page with a product list, a cart badge in the header and a small "Added!" message. When someone adds a product, all three need to know. Without events, the product code would need to call the badge code and the message code directly, and every new feature would need more direct calls. With a custom event, the product code simply announces "the cart changed", like a shop assistant making an announcement over the speakers. Anyone who cares is listening; the announcer does not need to know who they are.
Sending an event is called dispatching it. It takes two steps:
- Create the event object with
new CustomEvent(name, options)(ornew Event(name)if you have no data to send). - Dispatch it on an element with
element.dispatchEvent(event).
You listen for it with the normal addEventListener, exactly like a click.
Creating and dispatching an event
Here is the smallest working example:
const button = document.createElement("button");
button.textContent = "Like";
document.body.append(button);
// 1. Listen for our own event name
button.addEventListener("liked", () => {
console.log("Someone liked this post!");
});
// 2. Create and dispatch the event
const likedEvent = new Event("liked");
button.dispatchEvent(likedEvent);
// Output: Someone liked this post!
The listener runs as soon as dispatchEvent is called. Nothing was clicked: our own code sent the event.
One important detail: dispatchEvent is synchronous. That means all listeners run immediately, before the next line of your code, not later like a setTimeout:
document.addEventListener("app-ready", () => console.log("2. Listener runs"));
console.log("1. Before dispatch");
document.dispatchEvent(new Event("app-ready"));
console.log("3. After dispatch");
1. Before dispatch
2. Listener runs
3. After dispatch
Sending data with CustomEvent and detail
Usually you want to send information with the event: which product was added, the new total, and so on. Use CustomEvent and put your data in the detail option. Listeners read it from event.detail.
const profile = document.createElement("section");
document.body.append(profile);
profile.addEventListener("profile-saved", (event) => {
const { name, email } = event.detail;
console.log(`Saved profile for ${name} (${email})`);
});
profile.dispatchEvent(
new CustomEvent("profile-saved", {
detail: { name: "Nadia", email: "nadia@example.com" },
})
);
// Output: Saved profile for Nadia (nadia@example.com)
detail can hold anything: a number, a string, an object or an array. An object is usually best, because you can add more fields later without breaking listeners.
The options object for CustomEvent (and Event) accepts these settings:
| Option | Default | What it does |
|---|---|---|
detail | null | Your own data (CustomEvent only) |
bubbles | false | Should the event travel up to parent elements? |
cancelable | false | Can a listener call preventDefault() to say "please don't"? |
composed | false | Can it cross out of a Shadow DOM (used in Web Components)? |
Tip: Name custom events in lowercase with hyphens, like
"cart-updated", or with a prefix, like"shop:cart-updated". Event names are case-sensitive, and a clear name prevents clashes with built-in events.
The bubbles option: listening on a parent
Built-in events like click bubble: after running on the element itself, they travel up through every parent to document. That is what makes event delegation possible, as you saw in the Event Delegation lesson. Custom events do not bubble unless you set bubbles: true.
const list = document.createElement("ul");
const item = document.createElement("li");
item.textContent = "Blue T-shirt";
list.append(item);
document.body.append(list);
document.addEventListener("item-selected", (event) => {
console.log(`Document heard: ${event.detail.name}`);
});
// Without bubbles, only listeners on the <li> itself hear it:
item.dispatchEvent(new CustomEvent("item-selected", { detail: { name: "first try" } }));
// With bubbles, it travels up to <ul>, <body>, <html> and document:
item.dispatchEvent(new CustomEvent("item-selected", { detail: { name: "Blue T-shirt" }, bubbles: true }));
// Output: Document heard: Blue T-shirt
Only the second event reaches the listener on document. event.target is still the <li> that dispatched it, so the listener knows where it came from.
With cancelable: true, a listener can say "stop" with event.preventDefault(), and dispatchEvent then returns false:
document.addEventListener("before-delete", (event) => {
if (!confirm(`Delete "${event.detail.title}"?`)) {
event.preventDefault(); // say no
}
});
const allowed = document.dispatchEvent(
new CustomEvent("before-delete", { detail: { title: "Holiday photos" }, cancelable: true })
);
console.log(allowed ? "Deleting..." : "Delete cancelled");
// Output: Deleting... (or "Delete cancelled" if you pressed Cancel)
The code that sends the event decides what to do based on the return value. This is how you can let other parts of your page block an action.
Example: components talking with a cart-updated event
Let's build the shop page from the start of the lesson. The product buttons only update the cart and dispatch one event. The header badge and the message each listen on their own, and neither knows about the other.
<!DOCTYPE html>
<html lang="en">
<body>
<header>
<strong>My Shop</strong>
<span id="cart-badge">Cart: 0</span>
</header>
<ul id="products">
<li><button data-name="Mug" data-price="12">Add Mug ($12)</button></li>
<li><button data-name="Poster" data-price="20">Add Poster ($20)</button></li>
</ul>
<p id="message" aria-live="polite"></p>
<script>
const cart = [];
// Product list: changes the cart, then announces it
document.querySelector("#products").addEventListener("click", (event) => {
const button = event.target.closest("button");
if (!button) return;
const item = { name: button.dataset.name, price: Number(button.dataset.price) };
cart.push(item);
button.dispatchEvent(
new CustomEvent("cart-updated", {
bubbles: true,
detail: {
added: item,
count: cart.length,
total: cart.reduce((sum, product) => sum + product.price, 0),
},
})
);
});
// Header badge: only cares about the count and total
document.addEventListener("cart-updated", (event) => {
const { count, total } = event.detail;
document.querySelector("#cart-badge").textContent = `Cart: ${count} ($${total})`;
});
// Message: only cares about what was added
document.addEventListener("cart-updated", (event) => {
document.querySelector("#message").textContent = `${event.detail.added.name} added to your cart!`;
console.log(event.detail);
});
</script>
</body>
</html>
{ added: { name: 'Mug', price: 12 }, count: 1, total: 12 }
Click "Add Mug" and the badge changes to "Cart: 1 ($12)" while the message says "Mug added to your cart!". Click "Add Poster" and the badge shows "Cart: 2 ($32)". To add a new feature later, like saving the cart to localStorage, you just add one more cart-updated listener. The product code stays untouched.
Events are not only for DOM elements. Any class can send events by extending the built-in EventTarget class, which is handy for a data store that is not on the page:
class CartStore extends EventTarget {
#items = [];
add(item) {
this.#items.push(item);
this.dispatchEvent(new CustomEvent("change", { detail: { count: this.#items.length } }));
}
}
const store = new CartStore();
store.addEventListener("change", (event) => console.log(`Items: ${event.detail.count}`));
store.add({ name: "Mug" });
// Output: Items: 1
Common mistake: Dispatching on one element and listening on another without
bubbles: true. If you dispatch on a button and listen ondocument, nothing happens unless the event bubbles. Either addbubbles: true, or dispatch and listen on the same element (likedocument).
Practice exercise
Build a theme switcher. Add a "Dark mode" checkbox. When it changes, dispatch a theme-changed custom event on document with detail: { theme: "dark" } or { theme: "light" }. Add two separate listeners: one that sets document.body.className to the theme, and one that logs Theme is now dark to the console.
Hint: listen for the checkbox's change event, read checkbox.checked, and create the event with new CustomEvent("theme-changed", { detail: { theme } }).
Key takeaways
- Custom events are your own named events, created with
new Event()ornew CustomEvent()and sent withdispatchEvent(). - Put data in
detailand read it in the listener withevent.detail. - Custom events do not bubble by default; add
bubbles: trueto listen on a parent ordocument. dispatchEventruns all listeners immediately and returnsfalseif a cancelable event was cancelled.- Events let separate parts of a page communicate without knowing about each other.