Custom events
Create your own JavaScript events with CustomEvent, dispatch them with dispatchEvent, and decide when events are a better fit than callbacks.
- 01Send data with an eventMake a
CustomEventand put the data listeners need indetail. - 02Send and cancel eventsSay exactly when listeners run, and when
dispatchEventreturnsfalse. - 03Pick the right toolChoose between custom events, callbacks, promises, and
EventTargetclasses.
Make one part of your program announce what happened
An event is a message that says "something just happened". A listener is a function that waits for one kind of event and runs when it arrives. You already know built-in events, such as click and submit, from the earlier Events lessons. The browser sends those for you.
A custom event is an event you invent for your own app. You choose its name, such as cart:add, timer:done, or profile:saved. Your own code sends it.
Why is that useful? Say a product button adds tea to the cart. Three parts of the page care:
- the cart badge, which shows how many items are in the cart,
- the analytics logger, which counts what people buy,
- the discount banner, which may show a deal.
The button could call all three parts itself. But then the button must know about every one of them. Instead, the button can announce, "an item was added". Each part that cares listens for that announcement and reacts. The button doesn't need to know who is listening.
A teacher says over the school speakers, "Drama club meets in Room 4." The teacher doesn't know every student who cares. Students who listen can go to Room 4, write it down, or ignore it. A custom event works the same way in your code.
- In real life: The teacher speaks into the microphone
- In JavaScript: Calling
dispatchEvent(...) - In real life: Students who chose to listen
- In JavaScript: Functions added with
addEventListener - In real life: The message the teacher reads out
- In JavaScript: The data in
detail - In real life: The hallway speaker plays it too
- In JavaScript:
bubbles: truelets the elements around the target hear it too
A custom event is an event object with a name you made up for your app. Make it with new CustomEvent(name, options). Put any data the listeners need in detail. Send it with target.dispatchEvent(event). The target is the object you send the event from.
Send data with CustomEvent and detail
INTERACTIVEYou make a custom event with new CustomEvent(type, options). It takes two things:
type: the event's name, as a string, such as"cart:add".options: an object with settings. You can leave it out.
The options object can have four settings. Each one has a job:
detailholds the data you want to send, such as which item was added. If you leave it out, it isnull.bubblessays whether the event travels up to the elements around the target. It isfalseunless you set it.cancelablesays whether a listener may cancel the event. It isfalseunless you set it.composedis for Web Components; you'll see it near the end of the lesson. It isfalseunless you set it.
The plain Event class makes events too, with new Event(type, options). But it has no detail. So when listeners need data, use CustomEvent.
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);Lines 1 to 5 make the event. Line 1 names it cart:add. Line 2 puts an object with the tea's id, name, and quantity in detail. Lines 3 and 4 turn on bubbles and cancelable.
Lines 7 to 11 print five things:
- Line 7 prints the name,
cart:add. - Line 8 reads inside
detailand printsMint tea. - Lines 9 and 10 print
trueandtrue, because lines 3 and 4 turned those settings on. - Line 11 prints
false.isTrustedistrueonly for events that come from a real person, like a real click. Your code made this event, so it isfalse.
cart:add, and carries a detail object. A listener reads event.detail.name and gets Mint tea.Now leave the settings out. This example sets only detail. Then it prints the count and three settings.
const event = new CustomEvent("cart:updated", { detail: { count: 3 },}); console.log(event.detail.count);console.log(event.bubbles);console.log(event.cancelable);console.log(event.composed);Line 5 prints 3, the count from detail. Lines 6, 7, and 8 print false, false, and false. So an event doesn't bubble, can't be cancelled, and doesn't leave a Web Component, unless you ask for it.
Other code listens for your event by its name. So pick a name that says exactly what happened, such as cart:item-added. The colon is just a habit that makes names easy to read; any string works. What matters is that the name is clear, doesn't sound like a browser event, and is written down for your team.
const event = new CustomEvent("cart:item-added", { detail: { name: "tea" },}); console.log(event.type);console.log(event.detail.name);Line 1 names what happened in the app: an item was added to the cart. It is not a browser action like click. Line 2 puts the item's name in detail. Lines 5 and 6 print cart:item-added and tea. A listener can tell what happened and which item it was.
Events aren't only for buttons. Here a Cart class announces each change. extends EventTarget on line 1 gives the class the event methods; you'll learn more about that later in this lesson.
class Cart extends EventTarget { items = []; add(item) { this.items.push(item); this.dispatchEvent(new CustomEvent("cart:updated", { detail: { count: this.items.length, item }, })); }} const cart = new Cart();cart.addEventListener("cart:updated", (event) => { console.log("items", event.detail.count, "last", event.detail.item);});cart.add("tea");Line 5 adds the item to the list. Lines 6 to 8 send a cart:updated event. Its detail holds the new count and the item. Lines 13 to 15 add a listener. Line 16 adds tea, so the listener prints items 1 last tea.
detail doesn't make a copy. It holds the very value you pass in. If that value is an object, your code and every listener share the same object. So listeners should only read detail, not change it, unless sharing is what you want.
const cart = { count: 1 };const event = new CustomEvent("cart:updated", { detail: cart }); event.detail.count = 2;console.log(cart.count);Line 2 puts the cart object in detail. Line 4 changes count through event.detail. Line 5 prints 2: the original cart changed too, because it is the same object.
Now try it on a real page. The #shop element holds two product buttons and a cart badge. The buttons don't change the badge themselves. Each button sends a cart:add event with detail. A listener on #shop hears it and updates the badge and the log.
How can a listener on #shop hear an event sent from a button? Through bubbling. With bubbles: true, the event first reaches the button. Then it travels up to each element around the button, one by one. So #shop hears it too.
#shop, and the listener is on #shop. Only with bubbles: true does the event travel up from the button to reach it.Use the two toggle buttons in the demo to turn bubbles off, or to add a listener on the button that cancels the event. Then click Add and watch the badge.
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 an Add button. The buttons don't know who is listening.
dispatchEvent runs the listeners right away
STEP THROUGHtarget.dispatchEvent(event) sends the event from target. It runs the matching listeners synchronously. That means right now, one after another, in the order they were added. Only when they have all finished does the line after dispatchEvent run. There is no waiting, no timer, and no promise.
dispatchEvent also returns true or false. It is almost always true. It is false only when both of these are true:
- the event was made with
cancelable: true, and - at least one listener called
event.preventDefault(), which means "please don't do this".
That gives listeners a way to say no before something happens. In this example, a todo list asks its listeners before it deletes item 3.
const todo = new EventTarget(); todo.addEventListener("before-delete", (event) => { event.preventDefault();}); const ok = todo.dispatchEvent(new CustomEvent("before-delete", { detail: { id: 3 }, cancelable: true,})); console.log(ok);On line 4, the listener calls preventDefault(). Line 9 made the event cancelable, so that call counts. Line 7 stores false in ok, and line 12 prints it. The todo list can check ok and skip the delete.
const event = new CustomEvent("before-save"); event.preventDefault();console.log(event.defaultPrevented);console.log(new EventTarget().dispatchEvent(event));Line 1 makes an event without cancelable: true. Line 3 calls preventDefault(), but it is ignored. Line 4 prints false: defaultPrevented tells you whether the event was cancelled, and it wasn't. Line 5 sends the event from a new EventTarget, an object that can send events (more on it below). It prints true.
Now step through a full run. Two listeners write to a log array. Predict where their entries land in the log.
Predict the order of the log. Then step through a real EventTarget run, recorded in Node. The 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(" | "));dispatchEvent runs, before the next line.In browsers and in Node, an error thrown by a listener is reported, for example in the console. It is not thrown back out of dispatchEvent the usual way, and the other listeners still run. So don't use errors to send answers from a listener back to the sender.
Running listeners right away is useful. A button can ask "did any listener cancel this?" and get the answer on the next line. But it has a cost: a slow listener makes the sender wait. Keep listeners short. Move slow work into a timer, a promise, or another tool that runs later.
Give your own objects events with EventTarget
INTERACTIVEEventTarget is the built-in class that gives an object three methods:
addEventListener, to start listening,removeEventListener, to stop listening,dispatchEvent, to send an event.
Every element on a page already has these methods. But EventTarget isn't only for page elements. Modern browsers and Node 22 let you make one with new EventTarget(), or extend it in your own class. Then a timer, a downloader, a game level, or a small data store can announce what happened. It doesn't need to know anything about the page.
A school isn't the only building that can have speakers. A museum can put in its own, and visitors in every room hear the announcements. The museum doesn't need to know who is in each room.
- In real life: A school already has speakers
- In JavaScript: Page elements already have the event methods
- In real life: A museum puts in its own speakers
- In JavaScript: Your class can extend
EventTarget - In real life: People in many rooms can listen
- In JavaScript: Many separate listeners can sign up
For a small app, you can even use one plain EventTarget as an event bus. An event bus is one shared object that carries announcements between parts of the app. Each part listens only for the event names it needs.
const bus = new EventTarget(); bus.addEventListener("cart:updated", (event) => { console.log("badge", event.detail.count);}); bus.dispatchEvent(new CustomEvent("cart:updated", { detail: { count: 2 },}));Line 1 makes the bus. Lines 3 to 5 add a badge listener for cart:updated. Lines 7 to 9 send that event with a count of 2, so the listener prints badge 2.
The bus keeps no history and has no methods of its own. It only passes the news from the sender to the listener. In a bigger app, a named service or a framework store makes it clearer which code owns what, and which code removes old listeners.
Now try a Timer class that extends EventTarget. Each tick sends a tick event with the count in detail. After the last tick, it sends a done event. Two listeners hear each tick: a display that shows the count, and a logger that writes a row.
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 shows the count, and the logger writes a row.
Click Remove logger. The logger listener was added with a { signal } option. A signal comes from an AbortController. When the controller aborts, every listener added with its signal is removed. The display listener keeps working. This is often easier than keeping a copy of each listener function just so you can remove it later.
Events and callbacks compared
COMPAREA callback is a function you hand to other code, so that code can call it later. A callback reaches exactly one function. An event reaches every listener that signed up for it.
A radio station plays its show to anyone who tunes in. It doesn't know who is listening. A phone call is different: you were given one number, and you call that one person.
- In real life: The radio station
- In JavaScript: The code that sends the event
- In real life: People tune in or tune out
- In JavaScript:
addEventListener/removeEventListener - In real life: A phone call to one person
- In JavaScript: A callback function you were handed
So how do you choose? If many parts may care, send an event. If one caller needs one answer, use a callback or return a promise. A promise is an object that gives one result later. Both ways are useful. Pick the one where each part needs to know less about the others.
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 }, }));}Both versions report download progress at 25, 50, and 100 percent. In the first, downloadWithCallback calls the one onProgress function it was given. It prints bar 25, bar 50, and bar 100.
In the second, the loop on lines 11 to 15 sends a progress event three times. Two listeners hear each one: the bar listener and the analytics listener. So each percent prints twice: bar 25, then analytics 25, and so on.
The table compares the two. Each row asks one question; read across to compare the answers.
| Question | Callback or promise | Event |
|---|---|---|
| Who gets the message? | One function, or one caller waiting on a promise. | Every listener added for that event name. |
| What is it best for? | Getting one result, changing one step of a task, or reporting to one owner. | Telling many unrelated parts that something happened. |
| Can it send an answer back? | Yes. The function returns a value, or the promise gives one. | Not really. The only answer is false, when a listener cancels with preventDefault(). |
| What must the sender know? | It calls a function it was given. | It knows only the event name and its data. |
Event or callback?
SORTERSort these real situations. For each card, ask: does one caller need a direct answer? Or do many unrelated parts react to the same news?
- Product cards, a cart badge, and an analytics panel all react when an item is added to the cart.
items.sort((a, b) => a.price - b.price)- A pop-up window tells the component around it that the user clicked Save.
- One caller asks for a user profile and needs the final value, or the error.
- A timer 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 where the parts need to know the least about each other.
Where you will use custom events
Custom events help whenever a small feature should announce something without importing the rest of the app.
- Reusable widgets: a date picker sends
date-changewhen the user picks a date. - Web Components: a Web Component is a custom HTML element you build yourself. It can send a custom event to the page around it. A later lesson covers Web Components.
- Plain modules: a downloader or timer class extends
EventTarget. - Analytics: a feature sends
cart:add. The analytics code listens, and the feature's code doesn't change.
A Web Component can keep its inner parts in a hidden area called a shadow DOM. Normally, an event from inside that area stays inside. The composed: true setting lets the event cross out of it. It is a separate setting from bubbles.
const event = new CustomEvent("profile:save", { composed: true,}); console.log(event.composed);console.log(event.bubbles);Line 2 turns on composed, so line 5 prints true. Line 6 prints false. The event still doesn't travel up to the elements around it, because bubbles is its own setting.
Sending your own event named click is not a good way to make an element act as if it were clicked. To get a real click action from a link or button, call its built-in method, element.click(). Keep custom names, like cart:add, for things that happen in your own app.
Common mistakes and surprises
- “Custom events run later.” No.
dispatchEventruns every listener before it returns. - “Every custom event bubbles.” No.
bubblesisfalseunless you set it. - “
EventandCustomEventboth havedetail.” No. OnlyCustomEventhas it, so useCustomEventto send data. - “
dispatchEventgives back what the listeners return.” No. It returnstrueorfalse.falsemeans a listener cancelled the event. - “Events made by code count as real user actions.” No. Events your code makes have
isTrustedset tofalse. - “A custom event should reuse a name like
clickorchange.” No. Pick your own name, likecart:add, so no one mixes it up with a browser event.
The table lists each setting, its value when you leave it out, and why that matters.
| Setting | If you leave it out | Why it matters |
|---|---|---|
detail | null | Listeners get no data. To send data, use CustomEvent and set detail. |
bubbles | false | The event stays on its target. A container around the target won't hear it. |
cancelable | false | preventDefault() is ignored, so dispatchEvent still returns true. |
composed | false | The event can't leave a Web Component's shadow DOM. You'll use this with Web Components later. |
Practice exercises
5 EXERCISESRead the starter code. Predict the three lines it prints, in 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 listener, then after. dispatchEvent runs the listener right away, before line 5 runs.
A custom event carries some data. Which property do the listeners read to get it?
const event = new CustomEvent("user:save", { detail: { id: 7 } });
console.log(event.detail.id);The data a custom event carries is in event.detail. This example prints 7.
Predict the value that dispatchEvent returns: true or false.
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 the listener calls preventDefault(). So dispatchEvent returns false, and line 4 prints it.
Read the class. 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();timer.tick() sends one tick event. The listener reads event.detail.count, so it prints 1.
A profile loader has one caller. That caller waits for one final user object. Which is simpler: an event or a promise?
One caller needs one final result, so a promise or a callback is simpler. Use events when many unrelated listeners react to the same news.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which line makes an event that carries data for its listeners?
Choose an answer to see the explanation.
Question 2 of 7What does this code 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 this code print, in order?
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 7Which is a good name for a custom event?
Choose an answer to see the explanation.
Question 6 of 7What does
bubbles: truechange for a custom event sent from a page element?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
CustomEventcan carry data indetail. A plainEventcan't.dispatchEventruns every listener right away. It returnsfalseonly when the event is cancelable and a listener calledpreventDefault().- Give events clear names of your own. Turn on
bubbles,cancelable, orcomposedonly when you need them. - Your own classes can extend
EventTarget. It isn't only for page elements. - An event tells many listeners that something happened. A callback or a promise gets one result for one caller.
Custom events are named announcements that your code sends, so separate listeners can react to what happens in your app.
Up next: Mouse, pointer & touch.