Skip to content
JavaScript js events 5 min read

Custom Events

Browsers fire built-in events like click and submit, but you are not limited to them. The CustomEvent constructor lets you invent your own named events, attach arbitrary data to them, and dispatch them on any DOM node. This turns the event system into a lightweight, built-in pub/sub channel: one part of your app announces “something happened,” and any number of unrelated listeners react — without either side holding a direct reference to the other.

Creating a CustomEvent

A custom event is just an Event object with a name you choose and an optional payload. You create one with the CustomEvent constructor, then deliver it with target.dispatchEvent(event). Listeners subscribe with the same addEventListener API you already use for native events.

The constructor takes two arguments: the event type (a string name) and an options object. The most important option is detail, a free-form value that travels with the event and is read back on the receiving side as event.detail.

const event = new CustomEvent("cart:add", {
  detail: { productId: "sku-42", qty: 2 },
  bubbles: true,
  cancelable: true,
});
OptionTypeDefaultPurpose
detailanynullCustom payload available as event.detail
bubblesbooleanfalseWhether the event travels up the ancestor chain
cancelablebooleanfalseWhether event.preventDefault() has an effect
composedbooleanfalseWhether the event crosses shadow DOM boundaries

Unlike many native events, custom events do not bubble by default. If you want a delegated ancestor listener to catch them, you must pass bubbles: true explicitly.

Dispatching and listening

dispatchEvent runs synchronously: by the time the call returns, every matching listener has already executed. It returns false if the event was cancelable and some listener called preventDefault(), and true otherwise — handy for letting a listener veto an action.

const bus = document.querySelector("#bus");

bus.addEventListener("cart:add", (event) => {
  const { productId, qty } = event.detail;
  console.log(`Added ${qty} × ${productId}`);
});

bus.dispatchEvent(
  new CustomEvent("cart:add", { detail: { productId: "sku-42", qty: 2 } })
);

Output:

Added 2 × sku-42

Because listeners run synchronously, the dispatcher can inspect the result immediately:

const ok = bus.dispatchEvent(
  new CustomEvent("form:submit", { cancelable: true })
);
if (!ok) console.log("A listener cancelled the submit.");

A live custom-event demo

The pen below wires up a tiny click counter that broadcasts a count:changed custom event every time the value updates. A completely separate listener — which knows nothing about the button — listens for that event and renders a message. This separation is the whole point: the producer and consumer communicate only through the named event.

<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: system-ui, sans-serif; padding: 24px; }
    button { font-size: 1rem; padding: 10px 16px; cursor: pointer; }
    #status { margin-top: 16px; font-family: monospace; min-height: 1.4em; }
  </style>
</head>
<body>
  <button id="tick">Increment</button>
  <div id="status">Waiting for clicks…</div>

  <script>
    const button = document.querySelector("#tick");
    let count = 0;

    // Producer: announce a change. It does not touch the status div directly.
    button.addEventListener("click", () => {
      count += 1;
      button.dispatchEvent(
        new CustomEvent("count:changed", {
          detail: { count },
          bubbles: true, // let it reach the document-level listener below
        })
      );
    });

    // Consumer: a decoupled listener anywhere up the tree reacts to the event.
    document.addEventListener("count:changed", (event) => {
      const { count } = event.detail;
      const noun = count === 1 ? "time" : "times";
      document.querySelector("#status").textContent =
        `Clicked ${count} ${noun}.`;
    });
  </script>
</body>
</html>

Building a decoupled event bus

Custom events shine as the backbone of a pub/sub system. Any object that implements EventTarget can act as a message bus. In modern browsers and Node 18+ you can even construct a standalone EventTarget with no DOM at all, giving you a clean, framework-free way for modules to talk.

<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: system-ui, sans-serif; padding: 24px; }
    input, button { font-size: 1rem; padding: 8px; }
    #log { margin-top: 16px; font-family: monospace; }
    #log div { padding: 2px 0; }
  </style>
</head>
<body>
  <input id="name" placeholder="Your name" />
  <button id="join">Join</button>
  <div id="log"></div>

  <script>
    // A standalone event bus — no DOM node required.
    const bus = new EventTarget();
    const log = document.querySelector("#log");

    // Two independent subscribers, each unaware of the other.
    bus.addEventListener("user:joined", (e) => {
      const row = document.createElement("div");
      row.textContent = `👋 Welcome, ${e.detail.name}!`;
      log.appendChild(row);
    });

    bus.addEventListener("user:joined", (e) => {
      console.log("analytics: join", e.detail);
    });

    document.querySelector("#join").addEventListener("click", () => {
      const name = document.querySelector("#name").value.trim() || "Guest";
      bus.dispatchEvent(new CustomEvent("user:joined", { detail: { name } }));
    });
  </script>
</body>
</html>

This pattern keeps modules loosely coupled: the form publishes user:joined and never imports the UI logger or the analytics tracker. You can add or remove subscribers without ever touching the publisher.

CustomEvent vs Event

You can dispatch a plain Event too, but it cannot carry a payload — there is no detail. Reach for CustomEvent whenever you need to pass data along with the notification.

FeatureEventCustomEvent
Custom type nameYesYes
Carries a detail payloadNoYes
Bubbling / cancelable optionsYesYes
Typical useSignal-only eventsData-carrying events

Best Practices

  • Namespace event names (cart:add, user:joined) to avoid collisions with native events and other modules.
  • Put all data in the detail object rather than mutating shared state, so listeners stay self-contained.
  • Set bubbles: true only when you actually rely on delegation; otherwise dispatch directly on the relevant node.
  • Use a standalone new EventTarget() as an app-wide bus to decouple modules that should not import each other.
  • Remember dispatch is synchronous — keep listeners fast, and check the boolean return of dispatchEvent when the event is cancelable.
  • Always remove listeners (or use { once: true }) on long-lived buses to prevent memory leaks.
Last updated June 1, 2026
Was this helpful?