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.