A click away listener is a staple in most component libraries. Understanding how they work can shed light on the interaction between React's virtual DOM and the real DOM. First off, let's take a look at a common example.
Option 1
At first glance, there may not seem like there's anything special happening here. After all, dropdown have behaved this way since the beginning of the web. Clicking the dropdown box launches the options popover. And clicking outside of the component closes the options popover. But when you take a step back and analyze how and where state is managed in React's virtual DOM, the problem becomes more complex.
From a component hierarchy perspective, we're in a little bit of a bind. We want to manage the open/closed state of our dropdown options box inside the dropdown component, meaning we'll also need to handle the click away event inside our dropdown component.
Unfortunately, we don't have access to a synthetic click away event:
<>
{/* React does NOT give us access to a click away synthetic event */}
<div onClickAway={doSomethingAwesome}>
{/* React gives us access to the onClick synthetic event out of the box */}
<div onClick={doSomethingAwesomePart2}>
<p>hello world</p>
</div>
</div>
</>
So how can we keep all the state internal to this component while only having to handle the click away logic once? We do that by hooking in to native DOM events. I'll go over here a solution here, though it's important to always give credit where credit is due.
Here's the code for our custom ClickAwayListener:
import React, { useRef, useEffect, ReactNode } from "react";
interface IClickAwayListenerProps {
onClickAway: () => void;
children: ReactNode;
}
export default function ClickAwayListener(props: IClickAwayListenerProps) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const handleClickOutside: EventListener = (event: any) => {
if (ref.current && !ref.current.contains(event.target)) {
props.onClickAway();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [ref]);
return <div ref={ref}>{props.children}</div>;
}
So before I show an example of this component's interaction with our dropdown, let's unpack what's happening. When our click away listener is first mounted, it creates a ref which is just a mutable javascript object that persists between renders. We then pass that ref to our div and after the initial render, React updates the current key in our mutable javascript object, giving us a direct reference to the the actual div DOM element.
After the initial render, our useEffect runs, attaching a mousedown listener to the document. Whenever the listener is triggered (i.e. when the mousedown event happens anywhere in the document), our function checks to see if our wrapping div is a container for the event's target. If our div from this ClickAwayListener instance is not a container for the event's target, it's safe to run the onClickAway callback.
Implementation
Alright now that we've seen the setup for the component as a whole, let's take a look at how it would work within our dropdown component to ensure the open/closed state is isolated.
export function Dropdown(props: IDropdownProps) {
const [optionsAreShowing, setOptionsAreShowing] = useState(false);
function hideOptions() {
setOptionsAreShowing(false);
}
function showOptions() {
setOptionsAreShowing(true);
}
return (
<ClickAwayListener onClickAway={hideOptions}>
<InnerDropdownComponent onClick={showOptions} {...props} />
</ClickAwayListener>
);
}
We've managed to successfully isolate the open/closed state for our dropdown while creating a component that can easily be reused for other use cases.