Custom Events: Build Your Own Messaging System
Limitations of Browser Events
Browsers provide us with rich built-in events: click, input, submit, scroll, and more. These events cover most scenarios of user interaction with pages. But in actual development, we often need to handle more complex business logic.
For example, when a user completes a multi-step form process, we want to notify other components to update the user's points display; when the quantity of items in a shopping cart changes, we need to simultaneously update the shopping cart icon in the navigation bar and the total price in the sidebar. These scenarios all involve communication between different components, and browser built-in events cannot directly meet these needs.
This is where Custom Events come into play. They allow you to create your own event types, define the data events carry, and build flexible component communication mechanisms.
What Are Custom Events
Custom events are event types you define yourself, not preset by the browser. They use the same triggering and listening mechanisms as built-in events, have the same event flow (capturing and bubbling), and can carry custom data.
Custom events are like a "secret code system" you create for your application. Different modules communicate through these secret codes, and when one module sends out a specific secret code, other modules listening for that code react accordingly.
// Create a custom event
const event = new CustomEvent("userLoggedIn", {
detail: {
username: "Sarah",
userId: 12345,
timestamp: Date.now(),
},
});
// Listen for this custom event
document.addEventListener("userLoggedIn", (event) => {
console.log(`User ${event.detail.username} logged in`);
console.log("User ID:", event.detail.userId);
});
// Trigger this event
document.dispatchEvent(event);Creating Custom Events
JavaScript provides the CustomEvent constructor to create custom events. It accepts two parameters: event name and configuration object.
Basic Syntax
const event = new CustomEvent(eventType, eventOptions);- eventType: The name of the event (string), such as
'dataLoaded','cartUpdated','formValidated' - eventOptions: Optional configuration object containing the following properties:
detail: Any data used to pass additional information about the eventbubbles: Boolean, specifies whether the event bubbles (defaultfalse)cancelable: Boolean, specifies whether the event can be canceled (defaultfalse)composed: Boolean, specifies whether the event will cross Shadow DOM boundaries (defaultfalse)
Simple Examples
// Create an event that doesn't bubble and carries no data
const simpleEvent = new CustomEvent("taskCompleted");
// Create a bubbling event that carries data
const dataEvent = new CustomEvent("productAdded", {
bubbles: true,
detail: {
productId: "abc123",
productName: "Wireless Mouse",
quantity: 2,
price: 29.99,
},
});
// Create a cancelable event
const cancelableEvent = new CustomEvent("beforeSave", {
bubbles: true,
cancelable: true,
detail: {
data: {
/* ... */
},
},
});Triggering Custom Events
After creating an event, you need to use the dispatchEvent() method to trigger it. This method can be called on any DOM element or window and document objects.
// Trigger on document
document.dispatchEvent(event);
// Trigger on specific element
const button = document.getElementById("submit-btn");
button.dispatchEvent(event);
// Trigger on window
window.dispatchEvent(event);dispatchEvent() returns a boolean value:
- If the event is cancelable and a listener calls
event.preventDefault(), returnsfalse - Otherwise returns
true
const event = new CustomEvent("beforeDelete", {
cancelable: true,
detail: { itemId: 123 },
});
element.addEventListener("beforeDelete", (e) => {
if (!confirm("Are you sure?")) {
e.preventDefault(); // Prevent default behavior
}
});
const wasNotCancelled = element.dispatchEvent(event);
if (wasNotCancelled) {
// Event was not canceled, execute delete operation
deleteItem(123);
} else {
// Event was canceled, don't execute delete
console.log("Delete cancelled");
}Listening to Custom Events
Listening to custom events is exactly the same as listening to built-in events, using the addEventListener() method.
// Listen for custom event
element.addEventListener("customEventName", (event) => {
// Access data carried by event
console.log(event.detail);
});
// Can also use third parameter to control capture/bubble
element.addEventListener("customEventName", handler, {
capture: true, // Listen during capture phase
once: true, // Only listen once
passive: true, // Passive listener
});Passing Data: The detail Property
The detail property is one of the most powerful features of custom events. It can carry any type of data: objects, arrays, strings, numbers, even functions.
// Pass object
const event1 = new CustomEvent("userUpdated", {
detail: {
userId: 123,
changes: {
email: "[email protected]",
phone: "+1-555-0123",
},
timestamp: new Date(),
},
});
// Pass array
const event2 = new CustomEvent("itemsSelected", {
detail: {
items: [1, 5, 8, 12],
selectAll: false,
},
});
// Pass complex data structure
const event3 = new CustomEvent("chartDataReady", {
detail: {
datasets: [
{ label: "Revenue", data: [10, 20, 30, 40] },
{ label: "Expenses", data: [5, 15, 25, 35] },
],
labels: ["Jan", "Feb", "Mar", "Apr"],
options: {
responsive: true,
maintainAspectRatio: false,
},
},
});
// Listen and use data
document.addEventListener("chartDataReady", (event) => {
const { datasets, labels, options } = event.detail;
renderChart(datasets, labels, options);
});Real-World Application Scenarios
Scenario 1: Shopping Cart System
In e-commerce websites, shopping cart changes need to notify multiple components to update.
// Shopping cart module
class ShoppingCart {
constructor() {
this.items = [];
}
addItem(product, quantity) {
this.items.push({ product, quantity });
// Trigger custom event
const event = new CustomEvent("cartUpdated", {
bubbles: true,
detail: {
action: "add",
product: product,
quantity: quantity,
totalItems: this.getTotalItems(),
totalPrice: this.getTotalPrice(),
},
});
document.dispatchEvent(event);
}
removeItem(productId) {
const index = this.items.findIndex((item) => item.product.id === productId);
if (index > -1) {
const removed = this.items.splice(index, 1)[0];
const event = new CustomEvent("cartUpdated", {
bubbles: true,
detail: {
action: "remove",
product: removed.product,
totalItems: this.getTotalItems(),
totalPrice: this.getTotalPrice(),
},
});
document.dispatchEvent(event);
}
}
getTotalItems() {
return this.items.reduce((sum, item) => sum + item.quantity, 0);
}
getTotalPrice() {
return this.items.reduce(
(sum, item) => sum + item.product.price * item.quantity,
0
);
}
}
// Navigation cart icon component
class CartIcon {
constructor() {
this.badge = document.getElementById("cart-badge");
this.setupEventListeners();
}
setupEventListeners() {
document.addEventListener("cartUpdated", (event) => {
this.updateBadge(event.detail.totalItems);
});
}
updateBadge(count) {
this.badge.textContent = count;
this.badge.style.display = count > 0 ? "block" : "none";
}
}
// Shopping cart sidebar component
class CartSidebar {
constructor() {
this.sidebar = document.getElementById("cart-sidebar");
this.totalElement = document.getElementById("cart-total");
this.setupEventListeners();
}
setupEventListeners() {
document.addEventListener("cartUpdated", (event) => {
const { action, product, totalPrice } = event.detail;
if (action === "add") {
this.addItemToList(product);
} else if (action === "remove") {
this.removeItemFromList(product.id);
}
this.updateTotal(totalPrice);
});
}
addItemToList(product) {
// Add product to sidebar list
const item = document.createElement("div");
item.className = "cart-item";
item.dataset.productId = product.id;
item.innerHTML = `
<span>${product.name}</span>
<span>$${product.price}</span>
`;
this.sidebar.appendChild(item);
}
removeItemFromList(productId) {
const item = this.sidebar.querySelector(`[data-product-id="${productId}"]`);
if (item) item.remove();
}
updateTotal(total) {
this.totalElement.textContent = `$${total.toFixed(2)}`;
}
}
// Usage
const cart = new ShoppingCart();
const cartIcon = new CartIcon();
const cartSidebar = new CartSidebar();
// Adding products automatically updates all related components
cart.addItem({ id: 1, name: "Laptop", price: 999.99 }, 1);
cart.addItem({ id: 2, name: "Mouse", price: 29.99 }, 2);Scenario 2: Form Validation System
In multi-step forms, trigger events when each step completes to coordinate the overall flow.
// Form step manager
class FormWizard {
constructor() {
this.currentStep = 0;
this.steps = document.querySelectorAll(".form-step");
this.setupEventListeners();
}
setupEventListeners() {
// Listen for step completion event
document.addEventListener("stepCompleted", (event) => {
const { stepNumber, isValid, data } = event.detail;
if (isValid) {
this.saveStepData(stepNumber, data);
this.goToNextStep();
}
});
// Listen for entire form completion event
document.addEventListener("formCompleted", (event) => {
this.submitForm(event.detail.allData);
});
}
goToNextStep() {
this.steps[this.currentStep].classList.remove("active");
this.currentStep++;
if (this.currentStep < this.steps.length) {
this.steps[this.currentStep].classList.add("active");
} else {
// All steps completed
const event = new CustomEvent("formCompleted", {
detail: {
allData: this.getAllData(),
},
});
document.dispatchEvent(event);
}
}
saveStepData(stepNumber, data) {
sessionStorage.setItem(`step_${stepNumber}`, JSON.stringify(data));
}
getAllData() {
const allData = {};
for (let i = 0; i < this.steps.length; i++) {
const stepData = sessionStorage.getItem(`step_${i}`);
if (stepData) {
Object.assign(allData, JSON.parse(stepData));
}
}
return allData;
}
submitForm(data) {
console.log("Submitting form:", data);
// Send to server
}
}
// Single form step
class FormStep {
constructor(element, stepNumber) {
this.element = element;
this.stepNumber = stepNumber;
this.form = element.querySelector("form");
this.setupEventListeners();
}
setupEventListeners() {
this.form.addEventListener("submit", (e) => {
e.preventDefault();
this.validateAndComplete();
});
}
validateAndComplete() {
const formData = new FormData(this.form);
const data = Object.fromEntries(formData);
// Validate data
const isValid = this.validate(data);
// Trigger step completion event
const event = new CustomEvent("stepCompleted", {
bubbles: true,
detail: {
stepNumber: this.stepNumber,
isValid: isValid,
data: data,
},
});
this.element.dispatchEvent(event);
}
validate(data) {
// Validation logic
return true;
}
}
// Progress indicator
class ProgressIndicator {
constructor() {
this.indicator = document.getElementById("progress-indicator");
this.setupEventListeners();
}
setupEventListeners() {
document.addEventListener("stepCompleted", (event) => {
if (event.detail.isValid) {
this.updateProgress(event.detail.stepNumber + 1);
}
});
}
updateProgress(completedSteps) {
const totalSteps = 4;
const percentage = (completedSteps / totalSteps) * 100;
this.indicator.style.width = `${percentage}%`;
}
}Scenario 3: Real-Time Notification System
// Notification manager
class NotificationManager {
constructor() {
this.container = document.getElementById("notifications");
this.setupEventListeners();
}
setupEventListeners() {
// Listen to various notification events
document.addEventListener("notification:success", (e) => {
this.show(e.detail.message, "success");
});
document.addEventListener("notification:error", (e) => {
this.show(e.detail.message, "error");
});
document.addEventListener("notification:warning", (e) => {
this.show(e.detail.message, "warning");
});
document.addEventListener("notification:info", (e) => {
this.show(e.detail.message, "info");
});
}
show(message, type) {
const notification = document.createElement("div");
notification.className = `notification notification-${type}`;
notification.textContent = message;
this.container.appendChild(notification);
// Animate in
setTimeout(() => notification.classList.add("show"), 10);
// Auto remove
setTimeout(() => {
notification.classList.remove("show");
setTimeout(() => notification.remove(), 300);
}, 3000);
}
}
// Utility function: trigger notification
function showNotification(message, type = "info") {
const event = new CustomEvent(`notification:${type}`, {
detail: { message },
});
document.dispatchEvent(event);
}
// Use anywhere in the application
function saveUserProfile(data) {
fetch("/api/profile", {
method: "POST",
body: JSON.stringify(data),
})
.then((response) => {
if (response.ok) {
showNotification("Profile saved successfully!", "success");
} else {
showNotification("Failed to save profile", "error");
}
})
.catch(() => {
showNotification("Network error occurred", "error");
});
}Scenario 4: Plugin System
Custom events can be used to build plugin systems, allowing third-party code to extend application functionality.
// Main application
class Application {
constructor() {
this.plugins = [];
}
registerPlugin(plugin) {
this.plugins.push(plugin);
plugin.init(this);
}
render() {
// Trigger event before rendering
const beforeRenderEvent = new CustomEvent("app:beforeRender", {
cancelable: true,
detail: { app: this },
});
const shouldContinue = document.dispatchEvent(beforeRenderEvent);
if (!shouldContinue) return;
// Execute rendering
this.doRender();
// Trigger event after rendering
const afterRenderEvent = new CustomEvent("app:afterRender", {
detail: { app: this },
});
document.dispatchEvent(afterRenderEvent);
}
doRender() {
console.log("Rendering application...");
}
}
// Analytics plugin
class AnalyticsPlugin {
init(app) {
// Listen to application events
document.addEventListener("app:afterRender", () => {
this.trackPageView();
});
document.addEventListener("userLoggedIn", (e) => {
this.trackUser(e.detail.userId);
});
}
trackPageView() {
console.log("Analytics: Page view tracked");
}
trackUser(userId) {
console.log(`Analytics: User ${userId} tracked`);
}
}
// Logger plugin
class LoggerPlugin {
init(app) {
document.addEventListener("app:beforeRender", (e) => {
console.log("[Logger] App is about to render");
});
document.addEventListener("app:afterRender", (e) => {
console.log("[Logger] App has finished rendering");
});
}
}
// Usage
const app = new Application();
app.registerPlugin(new AnalyticsPlugin());
app.registerPlugin(new LoggerPlugin());
app.render();Custom Events vs Callback Functions
You might ask: Why use custom events? Isn't it simpler to just pass callback functions?
// Using callback functions
function saveData(data, onSuccess, onError) {
fetch("/api/save", { method: "POST", body: JSON.stringify(data) })
.then(() => onSuccess())
.catch(() => onError());
}
// Using custom events
function saveData(data) {
fetch("/api/save", { method: "POST", body: JSON.stringify(data) })
.then(() => {
document.dispatchEvent(
new CustomEvent("dataSaved", { detail: { data } })
);
})
.catch(() => {
document.dispatchEvent(
new CustomEvent("saveFailed", { detail: { data } })
);
});
}The advantages of custom events are:
1. Stronger Decoupling
When using callback functions, there's a clear dependency between the caller and the callee. Custom events are completely decoupled—the code triggering the event doesn't need to know who's listening, and listeners don't need to know who triggered the event.
// Callback: tight coupling
class DataService {
constructor(ui, logger, analytics) {
this.ui = ui;
this.logger = logger;
this.analytics = analytics;
}
save(data) {
// Need to know all dependencies
this.ui.showLoading();
this.logger.log("Saving...");
this.analytics.track("save_started");
// ...save logic
}
}
// Event: completely decoupled
class DataService {
save(data) {
document.dispatchEvent(
new CustomEvent("saveStarted", { detail: { data } })
);
// Don't need to know who's listening
// ...save logic
}
}
// Each module listens independently
document.addEventListener("saveStarted", (e) => ui.showLoading());
document.addEventListener("saveStarted", (e) => logger.log("Saving..."));
document.addEventListener("saveStarted", (e) =>
analytics.track("save_started")
);2. Multiple Listeners
One event can have multiple listeners, while a callback function can only be called once.
// Callback: can only notify one object
button.onclick = handleClick; // Only one handler function
// Event: can have multiple listeners
button.addEventListener("click", handleClick1);
button.addEventListener("click", handleClick2);
button.addEventListener("click", handleClick3);3. Leverage Event Bubbling
Custom events can utilize DOM event bubbling mechanism to implement event delegation.
// Listen to custom events from all child elements on parent container
const container = document.getElementById("container");
container.addEventListener("itemSelected", (e) => {
console.log("Item selected:", e.detail.itemId);
});
// Any child element can trigger, event bubbles to container
const item = document.getElementById("item-123");
item.dispatchEvent(
new CustomEvent("itemSelected", {
bubbles: true,
detail: { itemId: 123 },
})
);Common Issues and Best Practices
1. Event Naming Conventions
Use clear, descriptive names with consistent naming conventions.
// ✅ Good naming
new CustomEvent("userLoggedIn");
new CustomEvent("cart:itemAdded");
new CustomEvent("form:validated");
new CustomEvent("data:loaded");
// ❌ Bad naming
new CustomEvent("event1");
new CustomEvent("update");
new CustomEvent("done");Recommend using namespaces (like cart:itemAdded) to avoid naming conflicts.
2. When to Use bubbles
If you want events to be captured by parent elements (like using event delegation), set bubbles: true.
// Need bubbling
const event = new CustomEvent("notification:show", {
bubbles: true, // Allow event to bubble
detail: { message: "Hello" },
});
element.dispatchEvent(event);3. Avoid Passing DOM Elements in detail
Data in detail should be serializable, avoid passing DOM elements or functions.
// ❌ Avoid this
new CustomEvent("itemClicked", {
detail: {
element: document.getElementById("item"), // DOM element
callback: () => console.log("clicked"), // Function
},
});
// ✅ Recommended approach
new CustomEvent("itemClicked", {
detail: {
elementId: "item", // Pass ID
itemData: {
/* ... */
}, // Pass data
},
});4. Clean Up Event Listeners
Remember to remove event listeners when no longer needed to avoid memory leaks.
class Component {
constructor() {
this.handleDataUpdate = this.handleDataUpdate.bind(this);
}
init() {
document.addEventListener("dataUpdated", this.handleDataUpdate);
}
destroy() {
// Remove listener when component is destroyed
document.removeEventListener("dataUpdated", this.handleDataUpdate);
}
handleDataUpdate(event) {
console.log("Data updated:", event.detail);
}
}5. Use Type Checking (TypeScript)
If using TypeScript, you can define types for custom events.
// Define event detail type
interface CartUpdatedDetail {
action: "add" | "remove";
productId: string;
totalItems: number;
totalPrice: number;
}
// Create type-safe custom event
const event = new CustomEvent<CartUpdatedDetail>("cartUpdated", {
detail: {
action: "add",
productId: "abc123",
totalItems: 5,
totalPrice: 99.99,
},
});
// Listeners also have type hints
document.addEventListener(
"cartUpdated",
(e: CustomEvent<CartUpdatedDetail>) => {
console.log(e.detail.productId); // TypeScript knows this property exists
}
);Summary
Custom events are a powerful tool for building modular, extensible applications. They allow you to:
- Decouple Components: Modules don't need direct references, communicate through loosely coupled events
- Extend Functionality: Easily add new features by listening to corresponding events
- Build Plugin Systems: Allow third-party code to extend applications through event hooks
- Unified Communication Pattern: Use the same API as built-in events, maintaining code consistency
Key takeaways:
- Use
CustomEventconstructor to create events - Pass data through
detailproperty - Use
dispatchEvent()to trigger events - Set
bubbles: trueto let events bubble - Use clear naming conventions
- Clean up unneeded listeners promptly