Back

A Developer's Guide to JavaScript Custom Events

A Developer's Guide to JavaScript Custom Events

You’ve built a clean component architecture, but now pieces need to talk to each other without creating tight coupling. Native DOM events handle user interactions well, but what about your own application-specific signals? JavaScript Custom Events solve this problem elegantly.

This guide covers creating and dispatching custom events, passing structured data through the CustomEvent detail payload, and using EventTarget as a lightweight event bus. You’ll also learn how custom events work in Web Components and how Shadow DOM custom events propagate across boundaries.

Key Takeaways

  • Use the CustomEvent constructor with the detail property to pass structured data through events
  • The EventTarget interface works standalone, enabling lightweight pub/sub patterns without DOM elements
  • Set bubbles: true for events that should propagate up the DOM tree
  • Use composed: true to allow events to cross Shadow DOM boundaries in Web Components

Creating and Dispatching Custom Events

The CustomEvent constructor is the modern way to create custom events. Forget initCustomEvent—that’s a legacy API you won’t need in evergreen browsers.

const event = new CustomEvent('user-login', {
  detail: { userId: 123, timestamp: Date.now() },
  bubbles: true,
  cancelable: true
})

document.querySelector('#app').dispatchEvent(event)

Three options matter here:

  • detail: Your structured payload (any serializable data)
  • bubbles: Whether the event propagates up the DOM tree
  • cancelable: Whether listeners can call preventDefault()

Listening works exactly like native events:

document.querySelector('#app').addEventListener('user-login', (e) => {
  console.log(e.detail.userId) // 123
})

The CustomEvent Detail Payload

The detail property is where custom events shine over the basic Event constructor. While you could technically assign arbitrary properties to an Event object after creation, detail provides a dedicated, conflict-free namespace for your data.

const cartEvent = new CustomEvent('cart-updated', {
  detail: {
    items: [{ id: 1, qty: 2 }, { id: 3, qty: 1 }],
    total: 59.99,
    currency: 'USD'
  }
})

Handlers access this through event.detail—clean and predictable.

DOM EventTarget as an Event Bus

You don’t need a DOM element to use events. The EventTarget interface works standalone, making it perfect for a lightweight pub/sub mechanism:

class AppEventBus extends EventTarget {
  emit(eventName, data) {
    this.dispatchEvent(new CustomEvent(eventName, { detail: data }))
  }
  
  on(eventName, handler) {
    this.addEventListener(eventName, handler)
  }
  
  off(eventName, handler) {
    this.removeEventListener(eventName, handler)
  }
}

const bus = new AppEventBus()
bus.on('notification', (e) => console.log(e.detail.message))
bus.emit('notification', { message: 'Hello!' })

This EventTarget-based event bus pattern keeps components decoupled without external dependencies. For TypeScript users, you can type the detail payload using generics: CustomEvent<{ message: string }>.

Note: In Node.js, EventTarget exists but CustomEvent is only available as a global in Node 19+. Node’s EventEmitter remains the more common pattern there.

Custom Events in Web Components

Web Components rely heavily on custom events for outward communication. A component dispatches events while parent code listens:

class UserCard extends HTMLElement {
  connectedCallback() {
    this.addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('user-selected', {
        detail: { id: this.dataset.userId },
        bubbles: true,
        composed: true
      }))
    })
  }
}
customElements.define('user-card', UserCard)

The parent listens without knowing the component’s internals:

document.querySelector('user-card').addEventListener('user-selected', (e) => {
  loadUserProfile(e.detail.id)
})

Shadow DOM Custom Events and the Composed Option

Shadow DOM custom events behave differently based on the composed option:

  • composed: false (default): The event stops at the shadow root boundary. Internal implementation stays hidden.
  • composed: true: The event crosses shadow boundaries and bubbles through the light DOM.
// Inside shadow DOM
this.shadowRoot.querySelector('button').dispatchEvent(
  new CustomEvent('internal-action', {
    bubbles: true,
    composed: true // Escapes shadow boundary
  })
)

When an event crosses the shadow boundary, event.target gets retargeted to the host element—listeners outside see the component, not its internal structure.

Use composed: true for events that external code should handle. Keep composed: false for internal component communication.

A quick note on event.isTrusted: this property indicates whether the browser (user action) or script generated the event. It’s informational, not a security mechanism—don’t rely on it for access control.

Conclusion

JavaScript Custom Events provide a framework-agnostic way to build loosely coupled, event-driven architectures. Use CustomEvent with a structured detail payload for data, leverage EventTarget as a standalone event bus, and understand how bubbles and composed control propagation—especially across Shadow DOM boundaries. These patterns scale from simple component communication to complex micro-frontend architectures.

FAQs

The main difference is the detail property. CustomEvent includes a dedicated detail property for passing structured data, while the basic Event constructor doesn't. Although you can add properties to an Event object after creation, using CustomEvent with detail is cleaner and avoids potential naming conflicts with existing event properties.

Yes, but with caveats. React doesn't natively support custom event listeners in JSX, so you'll need to use refs and manually attach listeners with addEventListener. Vue handles custom events better through its event system. Custom events work best when communicating between framework components and vanilla Web Components or framework-agnostic code.

No. Set bubbles to true only when parent elements need to catch the event. For direct communication where you dispatch and listen on the same element, bubbling is unnecessary. Excessive bubbling can lead to unintended listeners catching events, so be intentional about propagation.

Use removeEventListener with the exact same function reference you passed to addEventListener. Anonymous functions cannot be removed because there's no reference to pass. Store your handler function in a variable or use the AbortController pattern with the signal option for cleaner cleanup.

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay