Talk to your React components with custom events
I build pages with both React and non-React components, and sometimes all these components need to talk to each other. Examples include opening a React modal when a customer clicks a button or updating a text block when a customer adds a product from a React stepper. There are many ways to do this, but in my opinion, the best way is to use custom events.
What are custom events?
Custom events are just like regular browser events (e.g. “click”, “keyup”, etc.) except they’re manually created. You can create a simple synthetic event with a custom type using the Event
constructor[1]:
const event = new Event('build');
document.dispatchEvent(event);
If you need to pass arbitrary data, you can use the CustomEvent
interface[2]:
const customEvent = new CustomEvent('build', { detail: { name: 'primary' } });
document.dispatchEvent(customEvent);
I use the document
element as the single event handler for all custom events because it centralizes all event methods and decouples custom events from specific nodes on the page.
document.addEventListener('build', function({ detail }) {
const { name } = detail;
...
}
Using a single entity to manage events makes this approach act like a browser-native publish-subscribe pattern. Benefits to this pattern include decoupling (previously mentioned) and scalability.
Example time!
I’ve built an example app with Create React App to illustrate this. The App
component includes a modal built with React Modal:
// App.js
import * as React from "react";
import Modal from "react-modal";
import "./style.css";
export default function App() {
const [isOpen, setIsOpen] = React.useState(false);
function closeModal() {
setIsOpen(false);
}
return (
<div>
<h1>Trigger modal outside React</h1>
<p>Custom events are AWESOME!</p>
<Modal isOpen={isOpen} onRequestClose={closeModal}>
<p>I was opened by a modal outside of React. How cool is that?</p>
<button onClick={closeModal}>Close</button>
</Modal>
</div>
);
}
The isOpen
prop determines the Modal
component open state. We then control this state using the useState
hook.
We will create a button outside of the React component that opens the React app modal. Let’s add the button to the page:
<!-- public/index.html -->
<!-- ... -->
<button id="open-button">I'm outside React</button>
<div id="root"></div>
<!-- ... -->
To make things a bit easier and reduce event boilerplate, I’ve put our event functions into a module:
// events.js
function on(eventType, listener) {
document.addEventListener(eventType, listener);
}
function off(eventType, listener) {
document.removeEventListener(eventType, listener);
}
function once(eventType, listener) {
on(eventType, handleEventOnce);
function handleEventOnce(event) {
listener(event);
off(eventType, handleEventOnce);
}
}
function trigger(eventType, data) {
const event = new CustomEvent(eventType, { detail: data });
document.dispatchEvent(event);
}
export { on, once, off, trigger };
You could go crazy and make this look more like traditional pub-sub implementations[3], or you could completely emulate the EventEmitter
interface if you want. Here I’ve tried to capture the most common functions.
Now that we have all the pieces in place we need to wire everything up.
Putting it together
The next step is to publish an event when the open button is clicked. For this example app, I’m going to do that in the index.js
file Create React App provides:
import React from "react";
import ReactDOM from "react-dom";
import { trigger } from "./events";
import App from "./App";
const openButton = document.getElementById("open-button");
openButton.addEventListener("click", function() {
trigger("openButton:click");
});
ReactDOM.render(<App />, document.getElementById("root"));
I’ve named the event openButton:click
. I typically follow a pattern of subject:verb
, mainly because that’s what I learned way back in my jQuery days. A nice benefit of this pattern is it reduces the possibility of event name collisions.
Finally, we’ll listen for that event inside the App
component and set the isOpen
state to true
when it’s triggered. Since adding event listeners is a side effect, we’ll use useEffect
to do that.
import * as React from "react";
import Modal from "react-modal";
import { on, off } from "./events";
import "./style.css";
export default function App() {
const [isOpen, setIsOpen] = React.useState(false);
const openModal = React.useCallback(() => setIsOpen(true), []);
React.useEffect(() => {
on("openButton:click", openModal);
return () => {
off("openButton:click", openModal);
}
}, [openModal]);
function closeModal() {
setIsOpen(false);
}
return (
<div>
<h1>Trigger modal outside React</h1>
<p>Custom events are AWESOME!</p>
<Modal isOpen={isOpen} onRequestClose={closeModal}>
<p>I was opened by a modal outside of React. How cool is that?</p>
<button onClick={closeModal}>Close</button>
</Modal>
</div>
);
}
And now it works (hopefully)! You can test it for yourself over on StackBlitz.
Update 2021-12-12
Many thanks to Johnny Pribyl for rightly pointing out that the useEffect
needed a clean up function returned, otherwise a new listener would be added every single time the modal was rendered. useEffect
also needs a dependency array to ensure it isn’t needlessly re-run. openModal
shouldn’t change, but there’s always the possibility that a new dependency may be added to that useCallback
.
Custom events are awesome indeed
Custom events are great when you need two completely separate entities to talk to each other, which is a common problem in UI design. Be aware, though, this pattern’s not all sunshine and rainbows. Drawbacks include an increased difficulty of maintenance (ghost events, or published events that are no longer listened to) and a higher degree of reasoning (indeterminate order of execution).
I hope I’ve at the very least piqued your interest in custom events, and maybe even given you a solution to a problem you’re dealing with right now. If that’s you, please do me a favor and like this article on DEV Community. And while you’re at it, follow me on Twitter so I don’t get lonely.
Until next time!
Note this code will not work in Internet Explorer (what does, amirite?). You will need to use the old-fashioned event constructor. ↩︎
The
CustomEvent
constructor is also unsupported in Internet Explorer (whomp whomp). They are created the same way asEvent
s, but initialize withinitCustomEvent
. ↩︎One addition could be a method to remove all event listeners for a particular event. You would need to manually track listeners in an object since there’s no way of directly accessing event listeners in native browser event handling. ↩︎