Topic 9.8
Custom Events
In one line
You can invent your own events: new CustomEvent("cart:add", { detail, bubbles: true }) creates one, element.dispatchEvent(event) fires it, and any code listening for that name reacts, so parts of an app can talk to each other without knowing about each other.
Think of it like this
A school bell. When the bell rings, every class knows break has started. The person ringing it doesn't walk to each classroom, and doesn't even need to know which classes exist. Classes added next year will hear it too. A custom event is a bell you design yourself: one part of your page rings it ("an item was added to the cart"), and any other part that cares can listen, without the two parts knowing each other.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Custom event
- An event you create and name yourself, instead of one the browser makes.
- CustomEvent
- The class for making a custom event that carries data in
detail. - detail
- The data attached to a custom event, read by listeners as
event.detail. - dispatchEvent
- The method that fires an event at a target so its listeners run.
- EventTarget
- The base class that gives any object
addEventListener,removeEventListeneranddispatchEvent. - Decoupling
- Designing parts of a program so they work without knowing about each other.
- Publish/subscribe
- A pattern where some code announces events and any number of listeners subscribe to them.
Step by step
01Create the event with data
Choose a name and put the data in detail. Set bubbles: true so listeners higher up (like document) can hear it.
const event = new CustomEvent("cart:add", {
bubbles: true,
detail: { id: 7, name: "Masala tea", price: 120 },
});02Fire it from where it happened
Dispatch it from the element where the action happened. Ancestors hear it as it bubbles up, just like a click.
addButton.dispatchEvent(event);03Listen anywhere above it
The cart badge doesn't know about product cards at all. It only knows the event name and the shape of detail.
document.addEventListener("cart:add", (event) => {
count += 1;
badge.textContent = count;
console.log("added", event.detail.name);
});04See who knows whom
The card publishes. Any number of listeners subscribe. None of them import each other.
05Ask permission with cancelable
Make the event cancelable and use dispatchEvent's return value. Any listener can veto the action by calling preventDefault().
const allowed = item.dispatchEvent(new CustomEvent("todo:before-delete", { bubbles: true, cancelable: true, detail: { id } }));
if (allowed) item.remove();Try it yourself
- 1
Add a third subscriber
In the first example, add a
documentlistener that writes the last added item's name into a new<p id="recent">. Predict: do you need to change the card code at all? - 2
Turn off bubbling
In the first example, delete
bubbles: true. Predict what the console shows when the buttons are clicked, then run it. - 3
Remove the veto
In the second example, change the event to
cancelable: false. Predict theallowedvalues and the number of items left.
Code & diagrams
Delete the analytics listener and the cart still works; delete the cart listener and the cards still work. That's decoupling.
Preview
Console
Nothing logged yet.Expected console output
cart:add { id: 1, name: 'Masala tea', price: 120 } | target: <div.card>
analytics: track 1
cart:add { id: 2, name: 'Samosa', price: 30 } | target: <div.card>
analytics: track 2The listener's line always appears between "before" and "after": dispatch runs listeners immediately, it doesn't queue them.
Preview
Console
Nothing logged yet.Expected console output
before dispatch
listener runs, checking item 1
after dispatch, allowed = true
before dispatch
listener runs, checking item 2
after dispatch, allowed = false
items left: 1No DOM element needed: any object that extends `EventTarget` can be listened to. `#settings` is a private field (ES2022, Topic 7.6), so listeners can read changes but can't edit the store directly.
Preview
Console
Nothing logged yet.Expected console output
changed { key: 'theme', old: 'light', value: 'dark' }
changed { key: 'fontSize', old: 16, value: 18 }
is an EventTarget? trueBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget bubbles: true
Dispatch new CustomEvent("cart:add", { detail }) from a button, with the listener on document.
Break #2
Read data from the wrong place
Create new CustomEvent("cart:add", { item }) and read event.item in the listener.
Myth vs fact
Myth
Custom events are asynchronous, like clicks.
Fact
dispatchEvent runs every listener synchronously and only then returns. A real click is queued by the browser, but once its turn comes it is dispatched the same way.
Myth
You need a library (or a framework) for pub/sub in the browser.
Fact
EventTarget and CustomEvent are built in everywhere, including web workers and Node.js (since version 15 for EventTarget, 19 for a global CustomEvent).
Myth
Custom events must be fired on DOM elements.
Fact
Any EventTarget works: new EventTarget(), or your own class that extends it.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Custom events inside a Shadow DOM component need
composed: true(andbubbles: true) to escape the shadow root and reach listeners in the page. When they do, outside listeners see the host element asevent.target(retargeting). Web components conventionally announce their state changes this way. - ▸
Custom events always have
isTrusted: false. User-activation features (opening a popup, entering full screen) depend on a recent real user action, not on the event object, so a custom event dispatched synchronously inside a real click listener still runs within that click's activation; one dispatched later, from a timer or a network reply, may not. - ▸
An exception thrown in one listener doesn't reach the code that called
dispatchEvent; it is reported as an uncaught error and the remaining listeners still run. If the dispatcher needs to know a listener failed, use a callback or a promise indetailinstead. - ▸
For cross-tab messages, events aren't enough: use
BroadcastChannelor thestorageevent. For cross-frame messages,postMessage(always checkevent.origin). TypeScript users can type custom events by extendingDocumentEventMap/HTMLElementEventMapsoevent.detailis typed in listeners.
Remember this
- 1
So far the browser created every event. You can also create your own with **
new CustomEvent(type, options)**. Thetypeis any name you choose, like"cart:add"or"todo-completed". Options:detail(any data you want to send along: a number, an object, an array),bubbles(defaultfalse!),cancelable(defaultfalse) andcomposed(whether it crosses a Shadow DOM boundary, defaultfalse). The plainnew Event(type, options)works too when you have no data to send. - 2
**
target.dispatchEvent(event)** fires it at a target. It travels exactly like a built-in event: capturing, target and (ifbubbles: true) bubbling, so delegation andstopPropagationwork the same way. Listeners receive it asevent, and read the data from **event.detail. Dispatch is synchronous**: every listener runs beforedispatchEventreturns, which surprises people who expect events to be queued like clicks. - 3
Why use them? Decoupling: the code that knows something happened doesn't need to know who cares. A product card dispatches
cart:add; the cart badge, an analytics tracker and a "recently added" panel each listen ondocument. You can add or remove listeners without touching the card's code. This is the publish/subscribe (pub/sub) or observer pattern, and the DOM gives it to you for free. - 4
dispatchEventreturns **false** if the event wascancelableand a listener calledpreventDefault(), otherwisetrue. That lets a component ask permission before doing something: dispatch"todo:before-delete"withcancelable: true, and only delete if the result istrue. Built-in elements follow the same idea; for example,<dialog>fires a cancelablecancelevent before closing on Escape. - 5
Events don't need a DOM element. **
EventTarget** is the base class behind every element,documentandwindow, and you can create one directly (new EventTarget()) or extend it with a class (class Store extends EventTarget, ES2015 class syntax, Topic 7.5). That gives any objectaddEventListener,removeEventListeneranddispatchEvent: a tiny event bus or a data store that announces changes, with no library needed. - 6
Naming: use lowercase and a prefix to avoid clashing with current or future built-in events, such as
cart:addorapp-theme-change. Event names are case-sensitive strings. Don't reuse built-in names like"click"or"change"for your own meaning: other code listening for the real ones would get confused. And keepdetailto plain data; passing live objects lets any listener change your component's internal state.
Explain it without notes
How do you create and dispatch a custom event with data?
What does it mean that custom events decouple components? Give an example.
What does the return value of dispatchEvent tell you, and how can you use it?
Practice
Make a <button id="like"> dispatch a bubbling post:like event with detail: { postId: 42 }, and a document listener that logs liked post 42. Click it from code.
Create a tiny event bus with new EventTarget() that has a notify event. Subscribe two listeners and dispatch once with the message Saved.
Trade-offs
- ↔
Events decouple parts of an app, but the flow becomes harder to follow: there is no direct call to search for, so name events clearly, document their
detailshape, and keep them few. - ↔
Bubbling custom events on
documentare easy to listen to from anywhere, but every event becomes global; dispatching on a dedicatedEventTargetstore keeps related events in one place.
Done when you can
I can create a
CustomEventwithdetailand dispatch it.I remember that custom events don't bubble unless I set
bubbles: true.I know
dispatchEventis synchronous and what its return value means.I can build a small store or event bus by extending
EventTarget.I name custom events so they can't clash with built-in ones.