Skip to content

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.

javascript
// 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

javascript
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 event
    • bubbles: Boolean, specifies whether the event bubbles (default false)
    • cancelable: Boolean, specifies whether the event can be canceled (default false)
    • composed: Boolean, specifies whether the event will cross Shadow DOM boundaries (default false)

Simple Examples

javascript
// 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.

javascript
// 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(), returns false
  • Otherwise returns true
javascript
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.

javascript
// 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.

javascript
// 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.

javascript
// 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.

javascript
// 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

javascript
// 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.

javascript
// 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?

javascript
// 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.

javascript
// 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.

javascript
// 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.

javascript
// 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.

javascript
// ✅ 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.

javascript
// 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.

javascript
// ❌ 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.

javascript
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.

typescript
// 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:

  1. Decouple Components: Modules don't need direct references, communicate through loosely coupled events
  2. Extend Functionality: Easily add new features by listening to corresponding events
  3. Build Plugin Systems: Allow third-party code to extend applications through event hooks
  4. Unified Communication Pattern: Use the same API as built-in events, maintaining code consistency

Key takeaways:

  • Use CustomEvent constructor to create events
  • Pass data through detail property
  • Use dispatchEvent() to trigger events
  • Set bubbles: true to let events bubble
  • Use clear naming conventions
  • Clean up unneeded listeners promptly