Skip to main content

How to Dispatch Custom Events in JavaScript

JavaScript's built-in events like click, submit, and keydown cover standard user interactions. But applications often need to communicate about things that have no built-in event: a shopping cart being updated, a user authentication state changing, a modal opening, a data fetch completing, or a custom component finishing its initialization. Custom events let you create your own event types, attach data to them, and dispatch them through the DOM just like native events.

Custom events follow the same propagation rules as built-in events. They bubble, they can be captured, they can be prevented, and they can be listened for with addEventListener. This makes them a powerful communication mechanism between different parts of your application, especially between loosely coupled components that should not directly call each other's methods.

This guide covers how to create, dispatch, and listen for custom events, how to attach data, and how the synchronous dispatching behavior affects your code.

Creating Events: new Event(type, options)

The Event constructor creates a new event object. You provide the event type as a string and optionally configure its behavior.

Basic Event Creation

const event = new Event("myCustomEvent");

This creates an event of type "myCustomEvent". The type can be any string you choose. By convention, custom event names use lowercase or kebab-case, but there is no technical restriction.

Event Options

The second argument is an options object with three properties:

const event = new Event("myCustomEvent", {
bubbles: false, // Does the event bubble up the DOM? (default: false)
cancelable: false, // Can preventDefault() be called? (default: false)
composed: false // Does it cross Shadow DOM boundaries? (default: false)
});

All three default to false. This is different from most built-in events, which typically bubble and are cancelable by default.

Comparison with Built-In Event Defaults

// Built-in click event: bubbles and is cancelable
document.addEventListener("click", (event) => {
console.log(event.bubbles); // true
console.log(event.cancelable); // true
});

// Custom event: defaults to NOT bubbling and NOT cancelable
const custom = new Event("custom");
console.log(custom.bubbles); // false
console.log(custom.cancelable); // false

If you want your custom event to behave like a built-in event, you need to explicitly set these options:

const event = new Event("custom", {
bubbles: true,
cancelable: true
});

Creating Specific Built-In Event Types

You can also use specialized event constructors to create events that match built-in types exactly:

// MouseEvent with coordinates, buttons, etc.
const clickEvent = new MouseEvent("click", {
bubbles: true,
cancelable: true,
clientX: 100,
clientY: 200
});

// KeyboardEvent with key information
const keyEvent = new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
key: "Enter",
code: "Enter"
});

// InputEvent
const inputEvent = new InputEvent("input", {
bubbles: true,
data: "hello"
});

These are useful for simulating user interactions in tests or triggering built-in behavior programmatically.

CustomEvent: Adding Custom Data with detail

The Event constructor creates events without custom data. To attach data to your event, use the CustomEvent constructor, which adds a detail property.

Basic CustomEvent

const event = new CustomEvent("user-login", {
detail: {
userId: 42,
username: "alice",
role: "admin",
timestamp: Date.now()
}
});

// Listeners access the data through event.detail
element.addEventListener("user-login", (event) => {
console.log(event.detail.username); // "alice"
console.log(event.detail.role); // "admin"
});

detail Can Hold Any Value

The detail property accepts any value: objects, arrays, numbers, strings, or even null:

// Object
new CustomEvent("data-loaded", {
detail: { items: [1, 2, 3], total: 100 }
});

// Array
new CustomEvent("items-selected", {
detail: ["apple", "banana", "cherry"]
});

// Primitive
new CustomEvent("score-changed", {
detail: 42
});

// null
new CustomEvent("session-cleared", {
detail: null
});

CustomEvent Also Accepts Bubbling and Cancelable Options

CustomEvent supports the same options as Event plus detail:

const event = new CustomEvent("cart-updated", {
bubbles: true, // Will bubble up the DOM
cancelable: true, // preventDefault() works
detail: {
items: ["Widget", "Gadget"],
total: 59.99,
currency: "USD"
}
});

Why Use CustomEvent Instead of Event

You might be tempted to just set a custom property on a regular Event:

// WORKS but NOT recommended
const event = new Event("data-loaded");
event.data = { items: [1, 2, 3] };

// The data is accessible but:
// 1. Not part of the standard event interface
// 2. Could conflict with future Event properties
// 3. Not idiomatic: other developers expect CustomEvent.detail
// RECOMMENDED: use CustomEvent
const event = new CustomEvent("data-loaded", {
detail: { items: [1, 2, 3] }
});

// Clear, standard, expected
element.addEventListener("data-loaded", (e) => {
console.log(e.detail.items); // Standard pattern
});

CustomEvent.detail is the standard way to carry data with events. It is part of the specification, clearly documented, and universally understood.

Real-World Example: Component Communication

// A notification component dispatches events when things happen
class NotificationManager {
#container;

constructor(containerId) {
this.#container = document.getElementById(containerId);
}

show(message, type = "info") {
const notification = document.createElement("div");
notification.className = `notification ${type}`;
notification.textContent = message;
this.#container.appendChild(notification);

// Dispatch event so other parts of the app can react
this.#container.dispatchEvent(new CustomEvent("notification-shown", {
bubbles: true,
detail: { message, type, element: notification }
}));

// Auto-dismiss after 5 seconds
setTimeout(() => {
notification.remove();
this.#container.dispatchEvent(new CustomEvent("notification-dismissed", {
bubbles: true,
detail: { message, type }
}));
}, 5000);
}
}

// Another part of the app listens
document.addEventListener("notification-shown", (event) => {
console.log(`Notification: [${event.detail.type}] ${event.detail.message}`);
analytics.track("notification_shown", event.detail);
});

dispatchEvent(): Triggering Events Programmatically

The dispatchEvent() method sends an event to an element. It follows the exact same propagation rules as user-triggered events: capture phase, target phase, bubble phase (if bubbles: true).

Basic Dispatching

const button = document.getElementById("my-button");

// Create the event
const event = new CustomEvent("activate", {
bubbles: true,
detail: { source: "keyboard-shortcut" }
});

// Dispatch it
button.dispatchEvent(event);

Any listeners for "activate" on the button (or on ancestors, if bubbling) will fire just as if a real user interaction had triggered the event.

Dispatching Built-In Events

You can dispatch built-in event types too:

const button = document.getElementById("submit-btn");

// Programmatically trigger a click
button.dispatchEvent(new MouseEvent("click", {
bubbles: true,
cancelable: true
}));

// Or more simply:
button.click(); // Built-in shortcut for click events
// Trigger an input event (useful after programmatically changing a value)
const input = document.getElementById("search");
input.value = "new search term";

// The input event doesn't fire automatically when you set .value
// You need to dispatch it manually
input.dispatchEvent(new Event("input", { bubbles: true }));
info

Setting an input's .value property programmatically does not trigger input or change events. If other code listens for those events, you must dispatch them manually after changing the value. This is a common gotcha when building forms or testing UI components.

dispatchEvent() Return Value

dispatchEvent() returns a boolean:

  • true if the event was NOT cancelled (no handler called preventDefault())
  • false if the event WAS cancelled (some handler called preventDefault())
const event = new CustomEvent("item-delete", {
bubbles: true,
cancelable: true,
detail: { itemId: 42 }
});

const wasAllowed = element.dispatchEvent(event);

if (wasAllowed) {
// No handler prevented the default: proceed with deletion
deleteItem(42);
} else {
// A handler called preventDefault(): abort
console.log("Deletion was prevented by a handler");
}

This return value is how you implement the "preventable custom action" pattern (covered in detail later).

Dispatching on Different Elements

You can dispatch events on any DOM node: elements, document, or window:

// On a specific element
document.getElementById("cart").dispatchEvent(
new CustomEvent("cart-updated", {
bubbles: true,
detail: { itemCount: 5 }
})
);

// On the document (good for app-wide events)
document.dispatchEvent(
new CustomEvent("theme-changed", {
detail: { theme: "dark" }
})
);

// On window (good for global events)
window.dispatchEvent(
new CustomEvent("app-initialized", {
detail: { version: "2.1.0" }
})
);

For app-wide events that do not relate to a specific element, dispatching on document or window is a common pattern. Since these events do not need to bubble (there is nothing above document or window), bubbles: false is fine.

Synchronous Dispatch Within Event Handlers

When you call dispatchEvent() inside an event handler, the dispatched event is processed synchronously. The new event's handlers run immediately, before the original handler continues. This is different from browser-triggered events, which are always queued and processed asynchronously.

Demonstrating Synchronous Behavior

const button = document.getElementById("btn");

button.addEventListener("click", (event) => {
console.log("1: Click handler start");

// Dispatch a custom event synchronously
button.dispatchEvent(new CustomEvent("custom-action", {
detail: { origin: "click handler" }
}));

console.log("3: Click handler end");
});

button.addEventListener("custom-action", (event) => {
console.log("2: Custom action handler runs");
});

// When the button is clicked:
// 1: Click handler start
// 2: Custom action handler runs ← runs synchronously, inline
// 3: Click handler end

The custom event handler runs between the log statements in the click handler. It does not wait for the click handler to finish.

Nested Event Dispatch

Synchronous dispatch means events can nest:

document.addEventListener("event-a", () => {
console.log("A start");
document.dispatchEvent(new Event("event-b"));
console.log("A end");
});

document.addEventListener("event-b", () => {
console.log("B start");
document.dispatchEvent(new Event("event-c"));
console.log("B end");
});

document.addEventListener("event-c", () => {
console.log("C");
});

document.dispatchEvent(new Event("event-a"));

Output:

A start
B start
C
B end
A end

The events nest like function calls. Event A dispatches B, B dispatches C, C completes, B completes, A completes.

When Synchronous Dispatch Matters

This behavior is important when the order of operations matters:

class Form {
#element;

constructor(element) {
this.#element = element;
}

submit() {
// Dispatch "before-submit" synchronously
const beforeEvent = new CustomEvent("before-submit", {
bubbles: true,
cancelable: true,
detail: { formData: this.#getFormData() }
});

const allowed = this.#element.dispatchEvent(beforeEvent);

if (!allowed) {
console.log("Submission prevented by before-submit handler");
return false;
}

// Perform submission
this.#sendData(beforeEvent.detail.formData);

// Dispatch "after-submit" synchronously
this.#element.dispatchEvent(new CustomEvent("after-submit", {
bubbles: true,
detail: { formData: beforeEvent.detail.formData }
}));

return true;
}

#getFormData() { /* ... */ }
#sendData(data) { /* ... */ }
}

// Usage
const form = new Form(document.getElementById("my-form"));

document.getElementById("my-form").addEventListener("before-submit", (event) => {
if (!isValid(event.detail.formData)) {
event.preventDefault(); // This runs synchronously BEFORE submit continues
}
});

Because dispatch is synchronous, the before-submit handler's preventDefault() call takes effect immediately, and the submit() method can check the result before proceeding.

Deferring with Asynchronous Dispatch

If you need the dispatched event to run after the current handler finishes, wrap it in a microtask or macrotask:

button.addEventListener("click", () => {
console.log("1: Click handler start");

// Asynchronous dispatch via microtask
queueMicrotask(() => {
button.dispatchEvent(new CustomEvent("deferred-action"));
});

console.log("2: Click handler end");
});

button.addEventListener("deferred-action", () => {
console.log("3: Deferred action runs");
});

// Output:
// 1: Click handler start
// 2: Click handler end
// 3: Deferred action runs

Bubbling Custom Events

By default, custom events do not bubble. To make them bubble through the DOM tree, set bubbles: true.

Non-Bubbling (Default)

const child = document.getElementById("child");
const parent = document.getElementById("parent");

parent.addEventListener("my-event", () => {
console.log("Parent caught it"); // Never fires
});

child.addEventListener("my-event", () => {
console.log("Child caught it"); // Fires
});

// bubbles defaults to false
child.dispatchEvent(new Event("my-event"));
// Output: "Child caught it", parent handler never runs

Bubbling Custom Events

const child = document.getElementById("child");
const parent = document.getElementById("parent");

parent.addEventListener("my-event", (event) => {
console.log("Parent caught it, target:", event.target.id);
});

child.addEventListener("my-event", (event) => {
console.log("Child caught it");
});

// Set bubbles: true
child.dispatchEvent(new Event("my-event", { bubbles: true }));

Output:

"Child caught it"
"Parent caught it, target: child"

When to Bubble and When Not To

Bubble (bubbles: true) when:

  • The event represents something that parent components might care about
  • You want to use event delegation
  • The event is analogous to a built-in event that bubbles (like click)
// A menu item was selected: parent menu should know
menuItem.dispatchEvent(new CustomEvent("item-selected", {
bubbles: true,
detail: { value: "settings" }
}));

// Cart item changed: the cart container should know
cartItem.dispatchEvent(new CustomEvent("quantity-changed", {
bubbles: true,
detail: { productId: 42, quantity: 3 }
}));

Do not bubble when:

  • The event is only relevant to the dispatching element
  • You are dispatching on document or window (nothing to bubble to)
  • You want to prevent unintended handlers from firing
// App-level events dispatched on document (no need to bubble)
document.dispatchEvent(new CustomEvent("app-ready", {
detail: { loadTime: 1234 }
}));

// Component-internal events
this.element.dispatchEvent(new CustomEvent("render-complete"));

Bubbling with Event Delegation

Bubbling custom events work beautifully with event delegation:

<div id="task-list">
<div class="task" data-id="1">
<span>Buy groceries</span>
<button class="complete-btn">Complete</button>
<button class="delete-btn">Delete</button>
</div>
<div class="task" data-id="2">
<span>Clean house</span>
<button class="complete-btn">Complete</button>
<button class="delete-btn">Delete</button>
</div>
</div>
// Each button dispatches a specific custom event that bubbles
document.getElementById("task-list").addEventListener("click", (event) => {
const task = event.target.closest(".task");
if (!task) return;

if (event.target.closest(".complete-btn")) {
task.dispatchEvent(new CustomEvent("task-complete", {
bubbles: true,
detail: { taskId: task.dataset.id }
}));
}

if (event.target.closest(".delete-btn")) {
task.dispatchEvent(new CustomEvent("task-delete", {
bubbles: true,
cancelable: true,
detail: { taskId: task.dataset.id }
}));
}
});

// Handle the custom events at the list level
document.getElementById("task-list").addEventListener("task-complete", (event) => {
console.log("Task completed:", event.detail.taskId);
event.target.classList.add("completed");
});

document.getElementById("task-list").addEventListener("task-delete", (event) => {
const confirmed = confirm("Delete this task?");
if (confirmed) {
console.log("Task deleted:", event.detail.taskId);
event.target.remove();
} else {
event.preventDefault(); // Signal that deletion was cancelled
}
});

event.target and event.currentTarget in Custom Events

Custom events follow the same target rules as built-in events:

const child = document.getElementById("child");
const parent = document.getElementById("parent");
const grandparent = document.getElementById("grandparent");

grandparent.addEventListener("status-change", (event) => {
console.log("target:", event.target.id); // "child" (where dispatch was called)
console.log("currentTarget:", event.currentTarget.id); // "grandparent" (where handler is)
console.log("detail:", event.detail);
});

child.dispatchEvent(new CustomEvent("status-change", {
bubbles: true,
detail: { status: "active" }
}));

Preventing Default on Custom Events

Just like built-in events, custom events can be made cancelable. When a handler calls preventDefault() on a cancelable custom event, the dispatcher can detect this through the return value of dispatchEvent() and act accordingly.

Basic Preventable Custom Event

const element = document.getElementById("editable");

element.addEventListener("before-edit", (event) => {
if (event.detail.field === "readonly-field") {
event.preventDefault(); // Prevent the edit
console.log("Editing this field is not allowed");
}
});

function startEdit(field) {
const event = new CustomEvent("before-edit", {
bubbles: true,
cancelable: true, // Must be true for preventDefault to work
detail: { field }
});

const allowed = element.dispatchEvent(event);

if (allowed) {
console.log(`Editing "${field}" - proceeding`);
showEditor(field);
} else {
console.log(`Editing "${field}" - cancelled`);
}
}

startEdit("name"); // "Editing 'name' (proceeding")
startEdit("readonly-field"); // "Editing this field is not allowed"
// "Editing 'readonly-field' (cancelled")

The Cancelable Pattern in Practice

This pattern is powerful for implementing extensible components where external code can veto actions:

class TabPanel {
#container;
#activeTab = null;

constructor(container) {
this.#container = container;
this.#container.addEventListener("click", (e) => {
const tab = e.target.closest("[data-tab]");
if (tab) this.switchTo(tab.dataset.tab);
});
}

switchTo(tabId) {
// Dispatch "before" event, can be cancelled
const beforeEvent = new CustomEvent("tab-before-switch", {
bubbles: true,
cancelable: true,
detail: {
currentTab: this.#activeTab,
newTab: tabId
}
});

if (!this.#container.dispatchEvent(beforeEvent)) {
// A handler prevented the switch
return false;
}

// Perform the switch
const previousTab = this.#activeTab;
this.#activeTab = tabId;
this.#render();

// Dispatch "after" event, NOT cancelable (already happened)
this.#container.dispatchEvent(new CustomEvent("tab-switched", {
bubbles: true,
cancelable: false,
detail: {
previousTab,
currentTab: tabId
}
}));

return true;
}

#render() { /* Update DOM to show active tab */ }
}

// External code can prevent tab switches
document.addEventListener("tab-before-switch", (event) => {
const { currentTab, newTab } = event.detail;

if (currentTab === "editor" && hasUnsavedChanges()) {
const leave = confirm("You have unsaved changes. Leave this tab?");
if (!leave) {
event.preventDefault(); // Block the tab switch
}
}
});

// External code can react to completed switches
document.addEventListener("tab-switched", (event) => {
console.log(`Switched from "${event.detail.previousTab}" to "${event.detail.currentTab}"`);
analytics.track("tab_switch", event.detail);
});

Non-Cancelable Events: When preventDefault() Is Ignored

If you create an event without cancelable: true, calling preventDefault() on it has no effect:

const event = new CustomEvent("notification", {
cancelable: false, // Default
detail: { message: "Hello" }
});

element.addEventListener("notification", (e) => {
e.preventDefault(); // Does nothing: event is not cancelable
console.log(e.defaultPrevented); // false
});

const result = element.dispatchEvent(event);
console.log(result); // true: always true for non-cancelable events

Use cancelable: false (or omit it, since false is the default) for events that represent something that has already happened and cannot be undone:

// "after" events should not be cancelable: the action already happened
new CustomEvent("data-loaded", { detail: data }); // Not cancelable
new CustomEvent("user-logged-in", { detail: user }); // Not cancelable
new CustomEvent("animation-complete", { detail: element }); // Not cancelable

// "before" events should be cancelable: the action can be vetoed
new CustomEvent("before-delete", { cancelable: true, detail: item });
new CustomEvent("before-navigate", { cancelable: true, detail: url });
new CustomEvent("before-close", { cancelable: true, detail: modal });

Complete Lifecycle Event Pattern

A robust component uses paired "before/after" events:

class Dialog {
#element;
#isOpen = false;

constructor(element) {
this.#element = element;
}

open(data) {
if (this.#isOpen) return;

// Before event: cancelable
const canOpen = this.#element.dispatchEvent(new CustomEvent("dialog-before-open", {
bubbles: true,
cancelable: true,
detail: { data }
}));

if (!canOpen) return; // Opening was prevented

// Perform the action
this.#isOpen = true;
this.#element.hidden = false;
this.#element.setAttribute("aria-hidden", "false");

// After event: not cancelable
this.#element.dispatchEvent(new CustomEvent("dialog-opened", {
bubbles: true,
detail: { data }
}));
}

close(reason = "user") {
if (!this.#isOpen) return;

// Before event: cancelable
const canClose = this.#element.dispatchEvent(new CustomEvent("dialog-before-close", {
bubbles: true,
cancelable: true,
detail: { reason }
}));

if (!canClose) return; // Closing was prevented

// Perform the action
this.#isOpen = false;
this.#element.hidden = true;
this.#element.setAttribute("aria-hidden", "true");

// After event: not cancelable
this.#element.dispatchEvent(new CustomEvent("dialog-closed", {
bubbles: true,
detail: { reason }
}));
}
}

// Consumer code
const dialog = new Dialog(document.getElementById("settings-dialog"));

document.addEventListener("dialog-before-close", (event) => {
if (event.detail.reason === "user" && hasUnsavedSettings()) {
if (!confirm("Discard unsaved changes?")) {
event.preventDefault(); // Keep the dialog open
}
}
});

document.addEventListener("dialog-opened", (event) => {
console.log("Dialog opened");
trapFocus(event.target);
});

document.addEventListener("dialog-closed", (event) => {
console.log("Dialog closed, reason:", event.detail.reason);
releaseFocus();
});

Summary

Custom events extend JavaScript's event system beyond built-in interactions, enabling clean communication between components, extensible APIs, and decoupled architectures.

ConceptKey Point
new Event(type, options)Creates a basic event. Options: bubbles, cancelable, composed. All default to false.
new CustomEvent(type, options)Creates an event with a detail property for custom data.
detail propertyThe standard way to attach data to custom events. Accepts any value.
element.dispatchEvent(event)Sends the event through the DOM. Returns true if not prevented, false if prevented.
Synchronous dispatchEvents dispatched inside handlers run immediately, before the outer handler continues.
bubbles: trueMakes the event propagate up through ancestors. Required for event delegation.
cancelable: trueAllows handlers to call preventDefault(). Required for vetoing actions.
dispatchEvent() return valuefalse if preventDefault() was called, true otherwise. Use this to implement vetoing.
Before/after patternbefore-* events are cancelable (can veto the action). after-* events are not (action already happened).
Event namingUse lowercase or kebab-case. Prefix with component/domain name to avoid collisions.

Key rules to remember:

  • Always use CustomEvent when you need to attach data. Use the detail property, not arbitrary properties on the event object.
  • Set bubbles: true when parent elements should be able to listen for the event.
  • Set cancelable: true when you want handlers to be able to veto an action.
  • Check the return value of dispatchEvent() to see if the event was prevented.
  • Custom events dispatched inside handlers are synchronous: they run inline, not deferred.
  • Pair "before" (cancelable) and "after" (non-cancelable) events for extensible component lifecycle management.
  • Setting .value on inputs does not automatically dispatch input or change events. Dispatch them manually.