Command Palette

Search for a command to run...

Hectal
PHASE 9Intermediate ~20 min· topic 8 of 8

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, removeEventListener and dispatchEvent.
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.

product-card.jswhole filejavascript
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.

product-card.jswhole filejavascript
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.

cart-badge.jswhole filejavascript
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.

See who knows whomdiagram
Rendering diagram…

05Ask permission with cancelable

Make the event cancelable and use dispatchEvent's return value. Any listener can veto the action by calling preventDefault().

todo.jswhole filejavascript
const allowed = item.dispatchEvent(new CustomEvent("todo:before-delete", { bubbles: true, cancelable: true, detail: { id } }));
if (allowed) item.remove();

Try it yourself

  1. 1

    Add a third subscriber

    In the first example, add a document listener 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. 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. 3

    Remove the veto

    In the second example, change the event to cancelable: false. Predict the allowed values and the number of items left.

Code & diagrams

A cart that listens for cart:add New tab

Delete the analytics listener and the cart still works; delete the cart listener and the cards still work. That's decoupling.

Preview

Press Run to see the page.

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 2
dispatchEvent is synchronous, and can be vetoed New tab

The listener's line always appears between "before" and "after": dispatch runs listeners immediately, it doesn't queue them.

Preview

Press Run to see the page.

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: 1
A store built on EventTarget ES2022+ New tab

No 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

changed { key: 'theme', old: 'light', value: 'dark' }
changed { key: 'fontSize', old: 16, value: 18 }
is an EventTarget? true

Break 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.

terminal
$ the page shows
── what you'll see ──
Nothing happens. The cart never updates and there is no error.

Break #2

Read data from the wrong place

Create new CustomEvent("cart:add", { item }) and read event.item in the listener.

terminal
$ Console
── what you'll see ──
Uncaught TypeError: Cannot read properties of undefined (reading 'price')

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 (and bubbles: true) to escape the shadow root and reach listeners in the page. When they do, outside listeners see the host element as event.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 in detail instead.

  • ▸

    For cross-tab messages, events aren't enough: use BroadcastChannel or the storage event. For cross-frame messages, postMessage (always check event.origin). TypeScript users can type custom events by extending DocumentEventMap/HTMLElementEventMap so event.detail is typed in listeners.

Remember this

  1. 1

    So far the browser created every event. You can also create your own with **new CustomEvent(type, options)**. The type is 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 (default false!), cancelable (default false) and composed (whether it crosses a Shadow DOM boundary, default false). The plain new Event(type, options) works too when you have no data to send.

  2. 2

    **target.dispatchEvent(event)** fires it at a target. It travels exactly like a built-in event: capturing, target and (if bubbles: true) bubbling, so delegation and stopPropagation work the same way. Listeners receive it as event, and read the data from **event.detail. Dispatch is synchronous**: every listener runs before dispatchEvent returns, which surprises people who expect events to be queued like clicks.

  3. 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 on document. 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. 4

    dispatchEvent returns **false** if the event was cancelable and a listener called preventDefault(), otherwise true. That lets a component ask permission before doing something: dispatch "todo:before-delete" with cancelable: true, and only delete if the result is true. Built-in elements follow the same idea; for example, <dialog> fires a cancelable cancel event before closing on Escape.

  5. 5

    Events don't need a DOM element. **EventTarget** is the base class behind every element, document and window, 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 object addEventListener, removeEventListener and dispatchEvent: a tiny event bus or a data store that announces changes, with no library needed.

  6. 6

    Naming: use lowercase and a prefix to avoid clashing with current or future built-in events, such as cart:add or app-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 keep detail to plain data; passing live objects lets any listener change your component's internal state.

Explain it without notes

01

How do you create and dispatch a custom event with data?

02

What does it mean that custom events decouple components? Give an example.

03

What does the return value of dispatchEvent tell you, and how can you use it?

Practice

01

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.

02

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 detail shape, and keep them few.

  • ↔

    Bubbling custom events on document are easy to listen to from anywhere, but every event becomes global; dispatching on a dedicated EventTarget store keeps related events in one place.

Done when you can

  • I can create a CustomEvent with detail and dispatch it.

  • I remember that custom events don't bubble unless I set bubbles: true.

  • I know dispatchEvent is 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.