docs/beyond/concepts/custom-events.mdx
What if you could create your own events, just like click or submit? What if a shopping cart could announce "item added!" and any part of your app could listen and respond? How do you build components that communicate without knowing about each other?
// Create a custom event with data
const event = new CustomEvent('userLoggedIn', {
detail: { username: 'alice', timestamp: Date.now() }
})
// Listen for the event anywhere in your app
document.addEventListener('userLoggedIn', (e) => {
console.log(`Welcome, ${e.detail.username}!`)
})
// Dispatch the event
document.dispatchEvent(event) // "Welcome, alice!"
The answer is custom events. They let you create your own event types, attach any data you want, and build applications where components communicate through events instead of direct function calls.
<Info> **What you'll learn in this guide:** - Creating events with the [`CustomEvent`](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent) constructor - Dispatching events with [`dispatchEvent()`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent) - Passing data through the [`detail`](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/detail) property - Event options: `bubbles`, `cancelable`, and when to use them - Building decoupled component communication - Differences between custom events and native browser events </Info> <Warning> **Prerequisites:** This guide assumes you understand [Event Bubbling and Capturing](/beyond/concepts/event-bubbling-capturing). If you're not familiar with how events propagate through the DOM, read that guide first. </Warning>A custom event is a developer-defined event that you create, dispatch, and listen for in JavaScript. Unlike built-in events like click or keydown triggered by user actions, custom events are triggered programmatically using dispatchEvent(). The CustomEvent constructor extends the base Event interface, adding a detail property for passing data to listeners. Can I Use data shows the CustomEvent constructor is supported in over 98% of browsers globally.
Think of custom events like a radio broadcast:
┌─────────────────────────────────────────────────────────────────────────────┐
│ CUSTOM EVENTS: THE RADIO ANALOGY │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ BROADCASTING (Dispatching) │
│ ───────────────────────── │
│ │
│ ┌─────────────┐ │
│ │ STATION │ ──── dispatchEvent() ────► 📻 "cart:updated" │
│ │ (Element) │ frequency (event type) │
│ └─────────────┘ │
│ │
│ LISTENING (Subscribing) │
│ ─────────────────────── │
│ │
│ 📻 "cart:updated" │
│ │ │
│ ┌─────────┼─────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ Header │ │ Badge │ │ Total │ All tuned to same frequency │
│ │Counter │ │ Icon │ │Display │ All receive the broadcast │
│ └────────┘ └────────┘ └────────┘ │
│ │
│ The station doesn't know (or care) who's listening. │
│ Listeners don't know (or care) where the broadcast comes from. │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
This decoupling is the superpower of custom events. As MDN's guide on creating and triggering events explains, this pub/sub pattern lets components communicate without importing each other or knowing each other exists.
To create a custom event, use the CustomEvent constructor:
const event = new CustomEvent('eventName', options)
The constructor takes two arguments:
type (required) - A string for the event name (case-sensitive)options (optional) - An object with configuration// Simplest custom event - just a name
const simpleEvent = new CustomEvent('hello')
// Custom event with data
const dataEvent = new CustomEvent('userAction', {
detail: { action: 'click', target: 'button' }
})
// Custom event with all options
const fullEvent = new CustomEvent('formSubmit', {
detail: { formId: 'login', data: { user: 'alice' } },
bubbles: true, // Event bubbles up the DOM
cancelable: true // preventDefault() will work
})
| Option | Default | Description |
|---|---|---|
detail | null | Any data you want to pass to listeners |
bubbles | false | If true, event propagates up through ancestors |
cancelable | false | If true, preventDefault() can cancel the event |
composed | false | If true, event can cross shadow DOM boundaries |
The detail property is what makes CustomEvent special. It can hold any JavaScript value:
// Primitive values
new CustomEvent('count', { detail: 42 })
new CustomEvent('message', { detail: 'Hello!' })
// Objects (most common)
new CustomEvent('userLoggedIn', {
detail: {
userId: 123,
username: 'alice',
timestamp: Date.now()
}
})
// Arrays
new CustomEvent('itemsSelected', {
detail: ['item1', 'item2', 'item3']
})
// Even functions (though rarely needed)
new CustomEvent('callback', {
detail: { getText: () => document.title }
})
The detail property is read-only and accessed through the event object:
document.addEventListener('userLoggedIn', (event) => {
// Access the detail property
console.log(event.detail.username) // "alice"
console.log(event.detail.userId) // 123
// detail is read-only - this won't work
event.detail = { different: 'data' } // Silently fails
// But you CAN mutate the object's properties (not recommended)
event.detail.username = 'bob' // Works, but avoid this
})
To trigger a custom event, call dispatchEvent() on any element:
const button = document.querySelector('#myButton')
// Create the event
const event = new CustomEvent('customClick', {
detail: { clickCount: 5 }
})
// Dispatch it on the button
button.dispatchEvent(event)
You can dispatch events on any EventTarget:
// On a specific element
document.querySelector('#cart').dispatchEvent(event)
// On the document (global events)
document.dispatchEvent(event)
// On window (also global)
window.dispatchEvent(event)
// On any element
someElement.dispatchEvent(event)
cart.addEventListener('cart:updated', (e) => {
console.log('Cart changed:', e.detail.items)
})
// Later, when cart changes...
cart.dispatchEvent(new CustomEvent('cart:updated', {
detail: { items: ['apple', 'banana'] }
}))
```
// From anywhere in the app...
document.dispatchEvent(new CustomEvent('app:themeChanged', {
detail: { theme: 'dark' }
}))
```
Unlike native browser events (which are processed asynchronously through the event loop), dispatchEvent() is synchronous. As the W3C DOM specification states, dispatching is a synchronous operation — all listeners execute immediately before dispatchEvent() returns:
console.log('1: Before dispatch')
document.addEventListener('myEvent', () => {
console.log('2: Inside listener')
})
document.dispatchEvent(new CustomEvent('myEvent'))
console.log('3: After dispatch')
// Output:
// 1: Before dispatch
// 2: Inside listener <-- Runs immediately!
// 3: After dispatch
Use addEventListener() to listen for custom events, just like native events:
// Add a listener
element.addEventListener('myCustomEvent', (event) => {
console.log('Received:', event.detail)
})
// You can add multiple listeners for the same event
element.addEventListener('myCustomEvent', handler1)
element.addEventListener('myCustomEvent', handler2) // Both will fire
// Remove a listener when no longer needed
element.removeEventListener('myCustomEvent', handler1)
// ✗ This doesn't work
element.onmyCustomEvent = handler // undefined, does nothing
// ✓ This works
element.addEventListener('myCustomEvent', handler)
By default, custom events don't bubble. Set bubbles: true if you want the event to propagate up through ancestor elements:
// Without bubbles (default) - only direct listeners receive the event
const nonBubblingEvent = new CustomEvent('test', {
detail: { value: 1 }
})
// With bubbles - ancestors can also listen
const bubblingEvent = new CustomEvent('test', {
detail: { value: 2 },
bubbles: true
})
// HTML: <div id="parent"><button id="child">Click</button></div>
const parent = document.querySelector('#parent')
const child = document.querySelector('#child')
// Listen on parent
parent.addEventListener('customClick', (e) => {
console.log('Parent heard:', e.detail.message)
})
// Dispatch from child WITHOUT bubbles
child.dispatchEvent(new CustomEvent('customClick', {
detail: { message: 'no bubbles' }
}))
// Parent hears nothing!
// Dispatch from child WITH bubbles
child.dispatchEvent(new CustomEvent('customClick', {
detail: { message: 'with bubbles' },
bubbles: true
}))
// Parent logs: "Parent heard: with bubbles"
If you create an event with cancelable: true, listeners can call preventDefault() to signal that the default action should be canceled:
const button = document.querySelector('#deleteButton')
// Listener can prevent the action
document.addEventListener('item:delete', (event) => {
if (!confirm('Are you sure you want to delete?')) {
event.preventDefault() // Signal cancellation
}
})
// Dispatch and check if it was canceled
function deleteItem(itemId) {
const event = new CustomEvent('item:delete', {
detail: { itemId },
cancelable: true // Required for preventDefault to work!
})
const wasAllowed = button.dispatchEvent(event)
if (wasAllowed) {
// No listener called preventDefault
console.log('Deleting item:', itemId)
} else {
// A listener called preventDefault
console.log('Deletion was canceled')
}
}
dispatchEvent() returns:
true if no listener called preventDefault()false if any listener called preventDefault() (and event was cancelable)const event = new CustomEvent('action', { cancelable: true })
element.addEventListener('action', (e) => {
e.preventDefault()
})
const result = element.dispatchEvent(event)
console.log(result) // false - event was canceled
Custom events shine when building decoupled components that need to communicate:
// Shopping Cart Component
class ShoppingCart {
constructor(element) {
this.element = element
this.items = []
}
addItem(item) {
this.items.push(item)
// Announce the change - anyone can listen!
this.element.dispatchEvent(new CustomEvent('cart:itemAdded', {
detail: { item, totalItems: this.items.length },
bubbles: true
}))
}
removeItem(itemId) {
this.items = this.items.filter(i => i.id !== itemId)
this.element.dispatchEvent(new CustomEvent('cart:itemRemoved', {
detail: { itemId, totalItems: this.items.length },
bubbles: true
}))
}
}
// Header Badge - listens for cart events
class CartBadge {
constructor(element) {
this.element = element
// Listen for ANY cart event that bubbles up
document.addEventListener('cart:itemAdded', (e) => {
this.update(e.detail.totalItems)
})
document.addEventListener('cart:itemRemoved', (e) => {
this.update(e.detail.totalItems)
})
}
update(count) {
this.element.textContent = count
}
}
// These components don't import each other - they communicate through events!
This pattern keeps components loosely coupled. The cart doesn't know the badge exists, and the badge doesn't know where cart events come from.
One key difference: custom events have event.isTrusted set to false:
// Native click from user
button.addEventListener('click', (e) => {
console.log(e.isTrusted) // true - real user action
})
// Custom event from code
button.addEventListener('customClick', (e) => {
console.log(e.isTrusted) // false - script-generated
})
button.dispatchEvent(new CustomEvent('customClick'))
| Feature | Native Events | Custom Events |
|---|---|---|
| Triggered by | Browser/User | Your code |
isTrusted | true | false |
| Processing | Asynchronous | Synchronous |
on* properties | Yes (onclick) | No |
detail property | No | Yes |
Default bubbles | Varies by event | false |
```javascript
// ✗ Won't bubble - parent won't hear it
child.dispatchEvent(new CustomEvent('notify', {
detail: { message: 'hello' }
}))
// ✓ Will bubble up to ancestors
child.dispatchEvent(new CustomEvent('notify', {
detail: { message: 'hello' },
bubbles: true
}))
```
```javascript
// ✗ Does nothing - onmyEvent doesn't exist
element.onmyEvent = () => console.log('fired')
// ✓ Use addEventListener instead
element.addEventListener('myEvent', () => console.log('fired'))
```
```javascript
// Listener on #sidebar
sidebar.addEventListener('update', handler)
// ✗ Dispatching on #header - sidebar won't hear it
header.dispatchEvent(new CustomEvent('update'))
// ✓ Dispatch on document for truly global events
document.dispatchEvent(new CustomEvent('update'))
```
```javascript
// ✗ preventDefault won't work
const event = new CustomEvent('submit')
element.addEventListener('submit', e => e.preventDefault())
element.dispatchEvent(event) // Returns true even with preventDefault!
// ✓ Add cancelable: true
const event = new CustomEvent('submit', { cancelable: true })
```
```javascript
let value = 'before'
element.addEventListener('sync', () => {
value = 'inside'
})
element.dispatchEvent(new CustomEvent('sync'))
// value is 'inside' immediately - not 'before'!
console.log(value) // "inside"
```
```javascript
// ✓ Good - clear namespace
new CustomEvent('cart:itemAdded')
new CustomEvent('modal:opened')
new CustomEvent('user:loggedIn')
// ✗ Avoid - could conflict with future browser events
new CustomEvent('update')
new CustomEvent('change')
```
```javascript
// ✗ Not enough context
new CustomEvent('item:deleted', {
detail: { success: true }
})
// ✓ Includes all relevant data
new CustomEvent('item:deleted', {
detail: {
itemId: 123,
itemName: 'Widget',
deletedAt: Date.now(),
remainingItems: 5
}
})
```
```javascript
/**
* Fired when an item is added to the cart
* @event cart:itemAdded
* @type {CustomEvent}
* @property {Object} detail
* @property {string} detail.itemId - The ID of the added item
* @property {string} detail.itemName - The name of the item
* @property {number} detail.quantity - Quantity added
* @property {number} detail.totalItems - New total items in cart
*/
```
```javascript
class Component {
constructor() {
this.handleEvent = this.handleEvent.bind(this)
document.addEventListener('app:update', this.handleEvent)
}
handleEvent(e) {
// Handle the event
}
destroy() {
// Clean up!
document.removeEventListener('app:update', this.handleEvent)
}
}
```
Create with new CustomEvent(type, options) - The constructor takes an event name and optional configuration object
Pass data with detail - The detail property can hold any JavaScript value and is accessible in listeners via event.detail
Dispatch with dispatchEvent() - Call this method on any element to fire the event; it executes synchronously
Set bubbles: true for propagation - By default, custom events don't bubble; enable it explicitly if needed
Set cancelable: true for preventDefault() - Without this option, preventDefault() silently does nothing
Use addEventListener(), not on* - Custom events don't have corresponding onclick-style properties
Custom events have isTrusted: false - This distinguishes them from real user-initiated events
Dispatch returns whether event was canceled - dispatchEvent() returns false if any listener called preventDefault()
Use namespaced event names - Prefix with component/feature name like cart:updated or modal:closed
Events enable loose coupling - Components can communicate without importing or knowing about each other
</Info>console.log(event.detail.value)
console.log(event.isTrusted)
```
**Answer:**
```
42
false
```
The `detail.value` is `42` as set in the constructor. `isTrusted` is `false` because the event was created programmatically, not by a real user action.
parent.addEventListener('notify', () => console.log('Parent heard it'))
child.dispatchEvent(new CustomEvent('notify', {
detail: { message: 'hello' }
}))
```
**Answer:**
No, the parent will not hear the event. Custom events have `bubbles: false` by default. To make it bubble up to the parent, add `bubbles: true`:
```javascript
child.dispatchEvent(new CustomEvent('notify', {
detail: { message: 'hello' },
bubbles: true
}))
```
element.addEventListener('action', (e) => {
e.preventDefault()
})
const result = element.dispatchEvent(event)
console.log(result)
```
**Answer:**
`false`
`dispatchEvent()` returns `false` when any listener calls `preventDefault()` on a cancelable event. This is useful for checking if an action should proceed.
**Answer:**
Custom events don't have corresponding `on*` properties like native events do. The `oncustomEvent` property doesn't exist and is just set to a function that's never called.
Use `addEventListener()` instead:
```javascript
element.addEventListener('customEvent', () => console.log('Fired!'))
element.dispatchEvent(new CustomEvent('customEvent'))
```
document.addEventListener('test', () => console.log('2'))
document.dispatchEvent(new CustomEvent('test'))
console.log('3')
```
**Answer:**
```
1
2
3
```
Unlike native browser events, `dispatchEvent()` is **synchronous**. The event handler runs immediately when `dispatchEvent()` is called, before the next line executes.
1. Create the event with `cancelable: true`
2. Check the return value of `dispatchEvent()`
```javascript
const event = new CustomEvent('beforeDelete', {
detail: { itemId: 123 },
cancelable: true
})
element.addEventListener('beforeDelete', (e) => {
if (!userConfirmed) {
e.preventDefault()
}
})
const shouldProceed = element.dispatchEvent(event)
if (shouldProceed) {
deleteItem(123)
} else {
console.log('Deletion was canceled')
}
```