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 events — onClick, 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 (onCartUpdated → cartupdated). 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.
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.
Related
- Framework Integration & Adapters — the parent topic on consuming custom elements in frameworks.
- Mapping Named Slots in Vue — the equivalent slot-projection gotcha in Vue.
- Projecting Angular Content into Web Components — Angular’s event and property binding model.
- Event Composition & Bubbling — making the event composed so it reaches React at all.
- Core Architecture & Lifecycle Management — the grandparent section.