Custom events
Create your own JavaScript events with CustomEvent, dispatch them with dispatchEvent, and decide when events are a better fit than callbacks.
- 01Package event dataUse
CustomEventand itsdetailproperty safely. - 02Dispatch and cancelPredict exactly when listeners run and what
dispatchEventreturns. - 03Design loose connectionsChoose between custom events, callbacks, promises, and
EventTargetclasses.
Make one part of your program announce what happened
A custom event is an event you invent for your own app: cart:add, timer:done, profile:saved, or any other name that describes something meaningful. Instead of one object calling another object directly, the first object announces, “this happened.” Any listener that cares can react.
You already know built-in events such as click and submit from the earlier Events lessons. Custom events use the same listener system, but the event type and the data are yours. That makes them perfect when a feature should be loosely connected: a product button should not need to know about the cart badge, the analytics logger, and the discount banner.
Imagine a teacher announces, “Drama club meets in Room 4.” The teacher does not know every student who cares. Anyone listening can move, write it down, or ignore it. A custom event is that announcement system for your code.
- In real life: Speaking into the PA
- In JavaScript: Calling
dispatchEvent(...) - In real life: People who chose to listen
- In JavaScript: Functions registered with
addEventListener - In real life: The message on the slip
- In JavaScript: The
detailpayload - In real life: The hallway speaker also plays it
- In JavaScript:
bubbles: truelets ancestors hear a DOM event
Where the analogy stops: A school PA is usually one-way sound. JavaScript listeners can also cancel a cancelable event with preventDefault(), which is more like a listener pressing a stop button.
A custom event is an Event object with an app-specific type. Use CustomEvent when listeners need extra data in event.detail, and send it with target.dispatchEvent(event).
CustomEvent & detail
INTERACTIVEThe constructor shape is new CustomEvent(type, init). The type is a string. The optional init object can include detail, bubbles, cancelable, and composed. bubbles and cancelable default to false; detail defaults to null. new Event(type, init) has no detail slot.
const event = new CustomEvent("cart:add", { detail: { id: "tea", name: "Mint tea", qty: 2 }, bubbles: true, cancelable: true,}); console.log(event.type);console.log(event.detail.name);console.log(event.bubbles);console.log(event.cancelable);console.log(event.isTrusted);Custom events you create in code are not user actions, so event.isTrusted is false. Choose distinctive names such as cart:add or cart-add so your app events do not sound like browser built-ins.
Try the cart. The product buttons do not update the badge directly. They dispatch a cart:add event with detail. A listener on the container updates the badge and log if the event bubbles up to it.
const shop = document.querySelector("#shop");const badge = document.querySelector("#cart-count");let items = 0; shop.addEventListener("cart:add", (event) => { items = items + event.detail.qty; badge.textContent = String(items); console.log("Added", event.detail.name);}); button.dispatchEvent(new CustomEvent("cart:add", { detail: { id: "tea", name: "Mint tea", qty: 1 }, bubbles: true, cancelable: true,}));Click Add. The product list does not know who is listening.
dispatchEvent in motion
STEP THROUGHdispatchEvent is not a timer and not a microtask. It runs matching listeners synchronously, in registration order, before the line after dispatchEvent continues. Its return value is usually true. It returns false only when the event is cancelable and at least one listener called preventDefault().
Predict the order. Then step through the real EventTarget run recorded in Node: listeners run during dispatchEvent, not later.
script
const target = new EventTarget();const log = []; target.addEventListener("status", (event) => { log.push("listener A: " + event.detail);}); target.addEventListener("status", (event) => { log.push("listener B");}); const ok = target.dispatchEvent( new CustomEvent("status", { detail: "ready", cancelable: true }),);log.push("after dispatch: " + ok); console.log(log.join(" | "));In browsers and in Node, an exception thrown by a listener is reported by the host rather than thrown back through the dispatchEvent call in the usual way, and other listeners can still run. Do not use thrown listener errors as a normal return channel.
That synchronous behavior is useful: a button can ask, “did anyone cancel this?” immediately. But it also means a slow listener blocks the dispatcher. Keep listeners short and move slow work to a timer, a promise, or another async API.
EventTarget in your own classes
INTERACTIVEEventTarget is not only for DOM nodes. Modern browsers and Node 22 expose constructible, subclassable EventTarget. Extending it is like installing your own PA system in any building: a timer, a downloader, a game level, or a tiny data store can broadcast facts without importing UI code.
The DOM is not the only “building” with speakers. If your own object extends EventTarget, outside code can add listeners without the object knowing who they are.
- In real life: The school already has speakers
- In JavaScript: DOM nodes already inherit event methods
- In real life: A museum installs its own PA
- In JavaScript: Your class can extend
EventTarget - In real life: Many rooms can listen
- In JavaScript: Many independent listeners can subscribe
Where the analogy stops: A PA system sends announcements; it does not store all past announcements. EventTarget listeners only hear events dispatched while they are registered.
class Timer extends EventTarget { constructor(limit = 3) { super(); this.count = 0; this.limit = limit; } tick() { this.count += 1; this.dispatchEvent(new CustomEvent("tick", { detail: { count: this.count, limit: this.limit }, })); if (this.count === this.limit) { this.dispatchEvent(new Event("done")); } }}- Timer ready.
Two listeners hear each tick: the display updates, and the logger writes a row.
Notice the removal pattern: the logger listener was added with { signal } and disappears when the AbortController aborts. That is often cleaner than saving every listener function just to remove it later.
Events vs callbacks
COMPAREA callback is like a phone call to one specific person: you were handed a number, and you call that number. An event is like a radio broadcast: anyone tuned to the frequency can hear it, and the broadcaster does not know who is listening.
If many parts may care, broadcast an event. If one caller needs one answer, call a callback or return a promise. Both patterns are useful; the skill is choosing the smaller coupling.
- In real life: Radio station
- In JavaScript: Event dispatcher
- In real life: Listeners tune in or out
- In JavaScript:
addEventListener/removeEventListener - In real life: Phone call to one person
- In JavaScript: A callback function you were handed
Where the analogy stops: Real radios cannot answer back. JavaScript events can be cancelable, but they are still best for notifications rather than asking one listener to compute a value.
function downloadWithCallback(onProgress) { onProgress(25); onProgress(50); onProgress(100);} downloadWithCallback((percent) => { console.log("bar", percent);});const download = new EventTarget(); download.addEventListener("progress", (event) => { console.log("bar", event.detail.percent);}); download.addEventListener("progress", (event) => { console.log("analytics", event.detail.percent);}); for (const percent of [25, 50, 100]) { download.dispatchEvent(new CustomEvent("progress", { detail: { percent }, }));}| Question | Callback / promise | Event |
|---|---|---|
| Who receives it? | One function or one awaiting caller. | Any listener registered for that type. |
| Best for | Computing one result, customizing one algorithm, reporting to one owner. | Broadcasting that something happened to unrelated parts. |
| Return channel | Direct return value or resolved promise. | No normal return value; maybe cancellation via preventDefault. |
| Coupling | Caller knows the callback exists. | Dispatcher only knows the event type and payload. |
Event or callback?
SORTERSort these realistic situations. Ask: does one caller need a direct answer, or are many unrelated parts reacting to the same fact?
- Product cards, a cart badge, and an analytics panel all react to the same add-to-cart action.
items.sort((a, b) => a.price - b.price)- A modal tells its parent component that the user clicked Save.
- One caller asks for a user profile and needs the final value or failure.
- A timer object lets a display, a sound effect, and a logger all react to every tick.
names.map((name) => name.toUpperCase())
Drag each card to the pattern that keeps the code least coupled.
Where you will use custom events
Custom events show up whenever a small feature should announce a fact without importing the rest of the app.
- Design-system widgets: a date picker dispatches
date-change. - Web Components: a component dispatches a custom event to the page.
composed: truelets an event cross a shadow DOM boundary; we will explore that later. - Plain modules: a downloader or timer class extends
EventTarget. - Analytics: a feature dispatches
cart:add; analytics listens without changing feature code.
Dispatching your own click event is not the same design as asking an element to do its built-in click behavior. For links and buttons in a browser, use the real DOM method such as element.click() when you need a click action. Use custom event names for your app facts.
Misconceptions and edge cases
- “Custom events are asynchronous.” No.
dispatchEventruns listeners before returning. - “Every custom event bubbles.” No.
bubblesdefaults tofalse. - “
EventandCustomEventboth havedetail.” No. UseCustomEventfor payloads. - “
dispatchEventreturns listener results.” No. It returns a cancellation boolean. - “Synthetic events are trusted user actions.” No. Script-created events have
isTrusted === false. - “A custom event name should reuse
clickorchange.” Prefer names likecart:addto avoid confusion.
| Option | Default | Why it matters |
|---|---|---|
detail | null | Listeners need CustomEvent and a detail payload when data should travel. |
bubbles | false | A container will not hear a DOM event from a child unless bubbling is enabled. |
cancelable | false | preventDefault() only changes dispatchEvent's return value for cancelable events. |
composed | false | Shadow DOM boundaries need composed: true; that matters for Web Components later. |
Practice exercises
5 EXERCISESRead the starter code and predict the exact log order.
const target = new EventTarget();
target.addEventListener("ping", () => console.log("listener"));
console.log("before");
target.dispatchEvent(new Event("ping"));
console.log("after");The output is before, then the listener, then after. Dispatch is synchronous.
Name the property listeners read when a custom event carries data.
const event = new CustomEvent("user:save", { detail: { id: 7 } });
console.log(event.detail.id);Custom payload data travels in event.detail.
Predict the boolean returned by dispatchEvent.
const target = new EventTarget();
target.addEventListener("save", (event) => event.preventDefault());
const ok = target.dispatchEvent(new Event("save", { cancelable: true }));
console.log(ok);The event is cancelable and a listener prevents default, so dispatchEvent returns false.
Study the class and predict what its listener prints.
class Timer extends EventTarget {
tick() {
this.dispatchEvent(new CustomEvent("tick", { detail: { count: 1 } }));
}
}
const timer = new Timer();
timer.addEventListener("tick", (event) => console.log(event.detail.count));
timer.tick();class Timer extends EventTarget {
tick() {
this.dispatchEvent(new CustomEvent("tick", { detail: { count: 1 } }));
}
}
const timer = new Timer();
timer.addEventListener("tick", (event) => console.log(event.detail.count));
timer.tick();The timer dispatches one tick, and the listener reads event.detail.count, so it prints 1.
A profile loader has one caller waiting for one final user object. Which pattern is simpler?
For one caller needing one final result, use a promise or callback. Use events when many unrelated listeners react to the same notification.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which constructor carries custom data for listeners?
Choose an answer to see the explanation.
Question 2 of 7What does the detail snippet print?
Read the code, then predictconst event = new CustomEvent("cart:add", { detail: { qty: 2 } }); console.log(event.detail.qty);Choose an answer to see the explanation.
Question 3 of 7What does the dispatch order snippet print?
Read the code, then predictconst target = new EventTarget(); target.addEventListener("ping", () => console.log("listener")); console.log("before"); target.dispatchEvent(new Event("ping")); console.log("after");Choose an answer to see the explanation.
Question 4 of 7When does
dispatchEvent(event)returnfalse?Choose an answer to see the explanation.
Question 5 of 7What is a good custom event name?
Choose an answer to see the explanation.
Question 6 of 7What does
bubbles: truechange for a DOM custom event?Choose an answer to see the explanation.
Question 7 of 7Why might a class extend
EventTarget?Choose an answer to see the explanation.
Key takeaways
CustomEventaddsdetail;Eventdoes not.dispatchEventis synchronous and returnsfalseonly for canceled cancelable events.- Use distinctive event names and opt into
bubbles,cancelable, orcomposedonly when needed. EventTargetworks in your own classes, not just DOM nodes.- Events broadcast facts; callbacks and promises ask one specific piece of code for a result.
Custom events are named announcements your code dispatches so independent listeners can react to app-specific facts.
Up next: Mouse, pointer & touch.