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 }));
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:
trueif the event was NOT cancelled (no handler calledpreventDefault())falseif the event WAS cancelled (some handler calledpreventDefault())
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
documentorwindow(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.
| Concept | Key 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 property | The 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 dispatch | Events dispatched inside handlers run immediately, before the outer handler continues. |
bubbles: true | Makes the event propagate up through ancestors. Required for event delegation. |
cancelable: true | Allows handlers to call preventDefault(). Required for vetoing actions. |
dispatchEvent() return value | false if preventDefault() was called, true otherwise. Use this to implement vetoing. |
| Before/after pattern | before-* events are cancelable (can veto the action). after-* events are not (action already happened). |
| Event naming | Use lowercase or kebab-case. Prefix with component/domain name to avoid collisions. |
Key rules to remember:
- Always use
CustomEventwhen you need to attach data. Use thedetailproperty, not arbitrary properties on the event object. - Set
bubbles: truewhen parent elements should be able to listen for the event. - Set
cancelable: truewhen 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
.valueon inputs does not automatically dispatchinputorchangeevents. Dispatch them manually.