Bridging Custom Events to React

A custom element that dispatches new CustomEvent('cart-updated', ...) works in plain HTML, Vue, and Angular, but the obvious React binding <my-cart onCartUpdated={handler}> never fires before React 19. The handler is silently ignored, no error is logged, and the bug surfaces only when a user action that should update state does nothing. This page isolates the exact cause and gives three production-safe fixes.

This is the deepest failure mode of Framework Integration & Adapters: React’s event model and the DOM’s CustomEvent model never met until React 19.

Minimal reproducible example

The element below is correct by every web-platform standard. It dispatches a composed custom event so the event crosses its shadow boundary.

class MyCart extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' }).innerHTML = '<button>Add</button>';
    this.shadowRoot.querySelector('button').addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('cart-updated', {
        detail: { count: 1 },
        bubbles: true,
        composed: true
      }));
    });
  }
}
customElements.define('my-cart', MyCart);

The React consumer looks reasonable and compiles without warning:

// React 18 — this handler NEVER runs.
function Cart() {
  const onUpdate = (e) => console.log('count:', e.detail.count);
  return <my-cart onCartUpdated={onUpdate} />;
}

Clicking the button dispatches cart-updated, but onUpdate is never called. There is no console error and no React warning. From the developer’s perspective the event has vanished.

Root-cause analysis

React (before version 19) does not attach a DOM listener for onCartUpdated. React’s DOM renderer recognizes only a fixed, hardcoded registry of synthetic eventsonClick, onChange, onInput, onSubmit, and the rest of the known DOM event set. These are wired up not on the element itself but through a single delegated listener at the React root, which dispatches into React’s SyntheticEvent system. A prop named onCartUpdated matches nothing in that registry.

Because the prop name begins with on and is camelCase, React also does not fall through and set it as an attribute (it would have rendered oncartupdated); it simply discards unknown event-shaped props on host elements. The result is the worst kind of failure: not an error, but a no-op.

A second, related root cause compounds this for rich data. React <19 sets every non-event unknown prop on a custom element by calling setAttribute(), never by assigning a JavaScript property. So even detail-carrying configuration passed as a prop is stringified. The event problem and the property problem share one origin: React <19 had no model for the custom-element contract and treated these elements as if they were unknown HTML with string attributes only.

React 19 rewrote this path. The renderer now attaches an actual addEventListener for on-prefixed camelCase handlers on custom elements, lowercasing the name to find the event (onCartUpdatedcartupdated). Note the casing: React 19 lowercases but does not insert hyphens, so an event literally named cart-updated is matched by onCartUpdated only because React strips to cartupdated and listens for that — meaning element authors targeting React 19 should be aware of the camelCase-to-lowercase mapping. The reliable cross-version fix does not depend on this nuance.

CustomEvent dispatch reaching React 18 versus React 19 A dispatched custom event is dropped by React 18's synthetic-event registry but received natively by React 19. <my-cart> dispatch cart-updated React 18 onCartUpdated no listener attached dropped silent no-op React 19 onCartUpdated addEventListener bound handler runs e.detail.count composed: true

Production-safe fix

Fix 1 — useRef + addEventListener in useEffect (works on every React version)

Attach the listener imperatively to the real DOM node and tear it down on cleanup. This is the canonical, version-independent fix.

import { useRef, useEffect } from 'react';

function Cart({ onCartUpdated }) {
  const ref = useRef(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const handler = (e) => onCartUpdated(e.detail);
    el.addEventListener('cart-updated', handler);
    // Cleanup is mandatory — without it, re-renders stack duplicate listeners.
    return () => el.removeEventListener('cart-updated', handler);
  }, [onCartUpdated]);

  return <my-cart ref={ref} />;
}

The [onCartUpdated] dependency re-binds only when the handler identity changes; memoize the parent’s handler with useCallback to keep this stable. The returned cleanup removes the exact same function reference, so no listener leaks across renders.

Fix 2 — a thin reusable wrapper component

Centralize the pattern so feature code stays declarative. This wrapper also forwards object props as properties, fixing the rich-data problem in the same place.

import { useRef, useEffect } from 'react';

function WcCart({ items, onCartUpdated }) {
  const ref = useRef(null);

  useEffect(() => {
    const el = ref.current;
    if (el) el.items = items;          // property assignment, not attribute
  }, [items]);

  useEffect(() => {
    const el = ref.current;
    if (!el || !onCartUpdated) return;
    const handler = (e) => onCartUpdated(e.detail);
    el.addEventListener('cart-updated', handler);
    return () => el.removeEventListener('cart-updated', handler);
  }, [onCartUpdated]);

  return <my-cart ref={ref} />;
}

Tooling such as @lit/react’s createComponent or Stencil’s React output target generates exactly this shape automatically, which is the recommended approach when you ship many elements.

Fix 3 — React 19 native binding

On React 19+, the workaround disappears entirely:

// React 19 — fires natively.
function Cart() {
  return <my-cart onCartUpdated={(e) => console.log(e.detail.count)} />;
}

React 19 attaches the listener for you and passes the native CustomEvent (so read e.detail, not a synthetic wrapper).

Verification

Confirm the fix concretely rather than by eyeballing the UI:

useEffect(() => {
  const el = ref.current;
  // 1. Listener is attached: this logs the real CustomEvent, with detail.
  const probe = (e) => console.log('received', e instanceof CustomEvent, e.detail);
  el.addEventListener('cart-updated', probe);
  return () => el.removeEventListener('cart-updated', probe);
}, []);

In Chrome DevTools, select the element and run getEventListeners($0) in the console — the cart-updated entry confirms the listener is bound to the host. For the leak check, click repeatedly and re-run getEventListeners($0); the count must stay at one. If it grows, a cleanup function is missing. A Playwright assertion that the handler ran exactly once per click closes the loop in CI.

When to use which fix

Situation Recommended approach
React 19+, new code Native onEventName — no workaround needed
React 16–18, a few elements Fix 1: useRef + addEventListener + cleanup
React 16–18, many elements Fix 2 / generated wrappers (@lit/react, Stencil)
Need rich-data props too Fix 2 — set properties via ref in the same wrapper
Library you ship to others Ship generated wrappers so consumers stay version-agnostic
Avoid at all costs onCartUpdated directly on React <19 — silent no-op

Prefer the native path once your floor is React 19. Until then, the wrapper in Fix 2 is the most maintainable choice because it solves events and properties together and concentrates teardown in one audited place. Contract-testing the event payload across these versions belongs in Distribution, Testing & Tooling.

Where a native custom event meets React's synthetic system in each major version React 18 has no synthetic counterpart so a ref and addEventListener are required, while React 19 wires unknown lowercase on-props directly to addEventListener. Dispatched: wfc-tab-change — two very different React stories React 18 and earlier onWfcTabChange → unknown prop, set as an attribute ref + addEventListener in useEffect return () => controller.abort() rich props also need the ref: attributes stringify React 19 onwfc-tab-change → addEventListener onWfcTabChange still does nothing rich props assign as properties the ref pattern still works, and is still the portable one Subscription lifetime under React Strict Mode Strict Mode mounts, unmounts and remounts in development, so an effect without cleanup registers twice while one that aborts stays at exactly one listener. Strict Mode: mount, unmount, mount again no cleanup returned listener 1 listener 2 every handler now fires twice controller.abort() returned listener 1 removed, then re-added exactly one, always Treat the double-fire as a failing test Strict Mode is showing a real defect that a production remount would also produce.

Frequently Asked Questions

Why does my onWfcChange prop never fire?

Because React only recognises the synthetic events it was built with. In React 18 an unknown on* prop is set as an attribute and does nothing; in React 19 it must be lowercase after ononwfc-change — to be wired to addEventListener.

Does Strict Mode's double mount break the listener?

Only if the effect does not clean up. React 18’s Strict Mode deliberately mounts, unmounts, and remounts in development, so an effect that returns an abort is correct and one that does not registers twice. Treat the double-fire as a failing test rather than noise.

Should the handler be in a dependency array?

Keep the effect’s dependencies empty and read the latest handler from a ref, or accept re-subscribing when the handler identity changes. Re-subscribing on every render is the common accidental outcome and is measurable on a list of components.

Is a generated wrapper component better than a ref?

For a library with many components, yes — it types the props, maps events, and assigns rich values as properties, all from the manifest. For a handful of usages the ref pattern is fine and has no build step.

Where should the listener live in a React component?

In an effect with an empty dependency array, registering against an AbortController whose abort is the cleanup. That subscribes once per mount, unsubscribes exactly once, and survives Strict Mode’s deliberate double mount without accumulating duplicates.

Can I forward the component's event as a React callback prop?

Yes, and that is what a generated wrapper does: it accepts onChange, subscribes to the native event internally, and calls the prop from the handler. Consumers then write ordinary React while the component stays framework-neutral.

Does event.stopPropagation inside the component affect React?

It can. React 18 attaches its synthetic listeners at the root container, so a component that stops propagation before the event reaches the root prevents React from ever seeing it — including for unrelated handlers on ancestors.

Should the ref be a callback ref or an object ref?

An object ref plus an effect is the common shape and re-subscribes only when the effect’s dependencies change. A callback ref subscribes and unsubscribes precisely when the element is attached and detached, which is more accurate for components that mount and unmount frequently, at the cost of slightly more code.

Does the component need to change to support React?

No, and it should not. Dispatching well-named composed events and exposing rich data as properties is everything React needs; the adapter belongs on the React side, where it can be generated from the component’s manifest rather than hand-written.