react: dispatch and listen to custom events
Asked Answered
P

2

14

I would like to integrate web components into my react app. They define a set of custom events that the enclosing react app must listen to.

According to the react docs:

Events emitted by a Web Component may not properly propagate through a React render tree. You will need to manually attach event handlers to handle these events within your React components.

I've struggled to make this work. I can dispatch custom events from react components and listen to them on dom elements. But I can't catch these events on other react components.

How do I correctly listen to custom events (and ideally, also be able to dispatch them) from a react app?

Minimal example:

I've set up a minimal example (edit live on sandbox.io). It consists of two divs, the outer one listens to my custom event, the inner one hosts the virtual dom. In the virtual dom there are two components. The outer one listens to the custom event, the inner one dispatches it. When run, the outer div registers the custom event. Inside of the react app, it is not caught.

If you want to play around with the code, I've set it up as a repo:

git clone https://github.com/lhk/react_custom_events
cd react_custom_events
npm i
npm run start
# browser opens, look at the console output

index.html, contains a div which will listen to custom dom elements, and inside of it is the root of the react app.

<div id='listener'>
  <div id="react_root"></div>
</div>

The react app consists of two functional components: Listener and Dispatcher. index.tsx renders the react app and sets up a listener on the dom div:

document.getElementById("listener")?.addEventListener("custom", ev => {
  console.log("dom received custom event");
});

ReactDOM.render(<Listener />, document.getElementById("react_root"));

And here are the two components, they dispatch and listen to the custom dom event. Listener.tsx:

import React, { useEffect, useRef } from "react";
import Dispatcher from "./Dispatcher";

export default function Listener() {
  const divRef = useRef(null);
  useEffect(() => {
    (divRef.current as any).addEventListener("custom", (ev:any) => {
        console.log("react received custom event");
      });
  });
  return (
    <div ref={divRef}>
      <Dispatcher />
    </div>
  );
}

Dispatcher.tsx:

import React, { useEffect, useRef } from "react";
import { customEvent } from "./events";

export default function Dispatcher() {
  const pRef = useRef(null);
  useEffect(() => {
    (pRef.current as any).dispatchEvent(customEvent);
  });
  return (
    <div>
      <p ref={pRef}>Some Text</p>
    </div>
  );
}

Finally, the custom event is declared like this:

export var customEvent = new Event('custom', { bubbles: true });

Related questions:

This question sounds very similar: Listening for web component events in React

But it's not really about the system of custom events in react. Instead it asks on how to expose an event attribute on a polymer component.

This question is also about custom events, but not in the context of react: How to listen for custom events defined web component

This question seems to be just a syntax error: addEventListener in a React app isn't working

Peasant answered 30/3, 2020 at 9:1 Comment(2)
I haven't tested anything, but based on this: coryrylan.com/blog/using-web-components-in-react, it looks like you can add event listeners to the actual web component tag using refs. You have to wrap the web components directly though, the events won't bubble.Erk
oh that's a nice blog post. I found this myself eventually (see the answer) but this would have saved me time.Peasant
P
9

It seems to me that this is simply not possible in react.

React offers a system of synthetic events. This has been implemented for compatibility reasons, the synthetic events expose the same API across browsers. Internally, react wraps native DOM events into synthetic events.

However, these synthetic events also introduce a series of limitations:

  • synthetic events don't cover all native events. Most notably there are changes around onInput and onChange.
  • there are no synthetic versions of custom events
  • native (custom) events will bubble through the virtual dom, without triggering any event listeners. After leaving the virtual dom, they will continue to bubble through the remainder of the dom. This is what happens in my example code.
  • it is not possible to dispatch synthetic events yourself

It is possible to listen to custom events in the virtual dom, but only on the same element that dispatches them. The following code will catch the event:

export default function Dispatcher() {
  const pRef = useRef(null);
  useEffect(() => {
    (pRef.current as any).addEventListener('custom', ()=>{
      console.log('react caught custom event directly on component');
    });
    (pRef.current as any).dispatchEvent(customEvent);
  });
  return (
    <div>
      <p ref={pRef}>Some Text</p>
    </div>
  );
}

I would argue that this makes custom events pretty much useless. If you have to get a reference to the specific dom node that triggered the custom event, you might as well give it a callback.

If you are interested in reading up on this, there are a variety of github issues. Unfortunately the facebook react team seems to moderate them quite heavily. They are simply closed without further comment :(

Peasant answered 30/3, 2020 at 14:0 Comment(4)
According to this answer it is possible to dispatch synthetic events. I tried it myself and it works: #23893047Thundersquall
@Thundersquall there are some subtleties: it is possible to listen to events on the element that has dispatched them. I haven't taken a look at the linked answer yet, but I assume that's what they do there. You can get a reference to the node that dispatches the event, listen to it there and then feed it into your state manager. This makes it possible to interface React with code that relies on native events. But it doesn't allow you to use the Event design pattern. However, with React 19, they are working on compatibility with native events. I'll eventually update my answer.Peasant
@Peasant just post the link in case if you still wonder, neither a "solution" nor a "workaround" , but somehow I guess technically interesting enough to combine your idea into a global storage to make it work. github.com/pmndrs/zustand/discussions/…Lemonade
How about dispatching onBlur event from inside of a an input component?Flagg
T
1

This is easily doable with the following:

customComponent.js:

this.dispatchEvent(new CustomEvent('some-event-name', {
  bubbles: true,
  detail: eventDataToSendAsEvent
}));

component.jsx:

const ref = useRef();
const handleEvent = (data) => {
  console.log('Event Handled', data);
};

useEventListener("some-event-name", handleEvent, ref.current);

useEventListener.js

import { useEffect } from 'react';

const useEventListener = (eventType, listener, targetElement = window) => {
  useEffect(() => {
    if (targetElement?.addEventListener) {
      targetElement.addEventListener(eventType, listener);
    }

    return () => {
      // https://github.com/niksy/throttle-debounce#cancelling
      if (listener?.cancel) {
        listener.cancel();
      }

      // Remove the event listeners
      if (targetElement?.removeEventListener) {
        targetElement.removeEventListener(eventType, listener);
      }
    };
  }, [eventType, listener, targetElement]);
};

export default useEventListener;

You can see a working example in this openapi explorer sandbox.

Thermosphere answered 22/7, 2022 at 9:36 Comment(7)
Hm, I'm not convinced that this works. In my experience, you can only catch an event on the component it's dispatched on, it doesn't bubble up through the virtual dom. So in your code, you'd have to pass the ref of the object where the event is dispatched around. When you pass a ref around, you don't really need the event system, you can just call a method directly, no? I think maybe I don't fully understand your code. Could you create a self contained example, maybe on jsfiddle? If I see it working in the way that events are intended, then I'll happily accept your answer :)Peasant
Or rather, I should say "I'm not convinced that this offers a design pattern that's actually equivalent to events". The code looks okPeasant
I've added a link to this working sandbox: codesandbox.io/s/determined-paper-s6v2yp?file=/src/…Thermosphere
Thanks. But looking at the code, I don't think this is really something new. Have you compared with the existing answer? I had already explained that you can listen to custom events, but only if you have a reference to the DOM node where they are dispatched.Peasant
You keep saying "have a reference to the DOM node". Your answer has a reference to the DOM node, mine does not. Mine catches the event dispatched by the openapi-explorer component.Thermosphere
Your code assigns a ref to the openapi-explorer, then uses ref.current to add event listeners. That's pretty much the same as in my answer. Yes, your code catches the event. So does mine. I really don't see how your answer adds something substantially new (apart from slightly different syntax).Peasant
I keep saying "reference to the DOM node" because you need to attach the event listener to the exact node that dispatches the event. The event won't bubble up in the virtual DOM. So you can't just have the openapi-explorer somewhere down in the virtual DOM, then listen for events higher up. What you can do is: you catch the event on the openapi-explorer and feed it into your state manager. Then you can listen to the state change globally. That makes sense. But at that point you've effectively bypassed the whole system of native DOM events.Peasant

© 2022 - 2024 — McMap. All rights reserved.