Custom Events
Browsers fire built-in events like click and submit, but you are not limited to them. The CustomEvent constructor lets you invent your own named events, attach arbitrary data to them, and dispatch them on any DOM node. This turns the event system into a lightweight, built-in pub/sub channel: one part of your app announces “something happened,” and any number of unrelated listeners react — without either side holding a direct reference to the other.
Creating a CustomEvent
A custom event is just an Event object with a name you choose and an optional payload. You create one with the CustomEvent constructor, then deliver it with target.dispatchEvent(event). Listeners subscribe with the same addEventListener API you already use for native events.
The constructor takes two arguments: the event type (a string name) and an options object. The most important option is detail, a free-form value that travels with the event and is read back on the receiving side as event.detail.
const event = new CustomEvent("cart:add", {
detail: { productId: "sku-42", qty: 2 },
bubbles: true,
cancelable: true,
});
| Option | Type | Default | Purpose |
|---|---|---|---|
detail | any | null | Custom payload available as event.detail |
bubbles | boolean | false | Whether the event travels up the ancestor chain |
cancelable | boolean | false | Whether event.preventDefault() has an effect |
composed | boolean | false | Whether the event crosses shadow DOM boundaries |
Unlike many native events, custom events do not bubble by default. If you want a delegated ancestor listener to catch them, you must pass
bubbles: trueexplicitly.
Dispatching and listening
dispatchEvent runs synchronously: by the time the call returns, every matching listener has already executed. It returns false if the event was cancelable and some listener called preventDefault(), and true otherwise — handy for letting a listener veto an action.
const bus = document.querySelector("#bus");
bus.addEventListener("cart:add", (event) => {
const { productId, qty } = event.detail;
console.log(`Added ${qty} × ${productId}`);
});
bus.dispatchEvent(
new CustomEvent("cart:add", { detail: { productId: "sku-42", qty: 2 } })
);
Output:
Added 2 × sku-42
Because listeners run synchronously, the dispatcher can inspect the result immediately:
const ok = bus.dispatchEvent(
new CustomEvent("form:submit", { cancelable: true })
);
if (!ok) console.log("A listener cancelled the submit.");
A live custom-event demo
The pen below wires up a tiny click counter that broadcasts a count:changed custom event every time the value updates. A completely separate listener — which knows nothing about the button — listens for that event and renders a message. This separation is the whole point: the producer and consumer communicate only through the named event.
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: system-ui, sans-serif; padding: 24px; }
button { font-size: 1rem; padding: 10px 16px; cursor: pointer; }
#status { margin-top: 16px; font-family: monospace; min-height: 1.4em; }
</style>
</head>
<body>
<button id="tick">Increment</button>
<div id="status">Waiting for clicks…</div>
<script>
const button = document.querySelector("#tick");
let count = 0;
// Producer: announce a change. It does not touch the status div directly.
button.addEventListener("click", () => {
count += 1;
button.dispatchEvent(
new CustomEvent("count:changed", {
detail: { count },
bubbles: true, // let it reach the document-level listener below
})
);
});
// Consumer: a decoupled listener anywhere up the tree reacts to the event.
document.addEventListener("count:changed", (event) => {
const { count } = event.detail;
const noun = count === 1 ? "time" : "times";
document.querySelector("#status").textContent =
`Clicked ${count} ${noun}.`;
});
</script>
</body>
</html>
Building a decoupled event bus
Custom events shine as the backbone of a pub/sub system. Any object that implements EventTarget can act as a message bus. In modern browsers and Node 18+ you can even construct a standalone EventTarget with no DOM at all, giving you a clean, framework-free way for modules to talk.
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: system-ui, sans-serif; padding: 24px; }
input, button { font-size: 1rem; padding: 8px; }
#log { margin-top: 16px; font-family: monospace; }
#log div { padding: 2px 0; }
</style>
</head>
<body>
<input id="name" placeholder="Your name" />
<button id="join">Join</button>
<div id="log"></div>
<script>
// A standalone event bus — no DOM node required.
const bus = new EventTarget();
const log = document.querySelector("#log");
// Two independent subscribers, each unaware of the other.
bus.addEventListener("user:joined", (e) => {
const row = document.createElement("div");
row.textContent = `👋 Welcome, ${e.detail.name}!`;
log.appendChild(row);
});
bus.addEventListener("user:joined", (e) => {
console.log("analytics: join", e.detail);
});
document.querySelector("#join").addEventListener("click", () => {
const name = document.querySelector("#name").value.trim() || "Guest";
bus.dispatchEvent(new CustomEvent("user:joined", { detail: { name } }));
});
</script>
</body>
</html>
This pattern keeps modules loosely coupled: the form publishes user:joined and never imports the UI logger or the analytics tracker. You can add or remove subscribers without ever touching the publisher.
CustomEvent vs Event
You can dispatch a plain Event too, but it cannot carry a payload — there is no detail. Reach for CustomEvent whenever you need to pass data along with the notification.
| Feature | Event | CustomEvent |
|---|---|---|
| Custom type name | Yes | Yes |
Carries a detail payload | No | Yes |
| Bubbling / cancelable options | Yes | Yes |
| Typical use | Signal-only events | Data-carrying events |
Best Practices
- Namespace event names (
cart:add,user:joined) to avoid collisions with native events and other modules. - Put all data in the
detailobject rather than mutating shared state, so listeners stay self-contained. - Set
bubbles: trueonly when you actually rely on delegation; otherwise dispatch directly on the relevant node. - Use a standalone
new EventTarget()as an app-wide bus to decouple modules that should not import each other. - Remember dispatch is synchronous — keep listeners fast, and check the boolean return of
dispatchEventwhen the event iscancelable. - Always remove listeners (or use
{ once: true }) on long-lived buses to prevent memory leaks.