Oftentimes we need to detect when a click has happened outside of an element or when the focus has shifted outside of it. Some of the evident examples for this use case are fly-out menus, dropdowns, tooltips and popovers. Let’s start the process of making this detection functionality.
The DOM Way To Detect Outside Click
If you were asked to write code to detect if a click happened inside a DOM node or outside of it, what would you do? Chances are you’d use the Node.contains
DOM API. Here’s how MDN explains it:
The Node.contains()
method returns a Boolean
value indicating whether a node is a descendant of a given node, i.e. the node itself, one of its direct children (childNodes
), one of the children’s direct children, and so on.
Let’s quickly test it out. Let’s make an element we want to detect outside click for. I’ve conveniently given it a click-text
class.
<section>
<div class="click-text">
click inside and outside me
</div>
</section>
const concernedElement = document.querySelector(".click-text");
document.addEventListener("mousedown", (event) => {
if (concernedElement.contains(event.target)) {
console.log("Clicked Inside");
} else {
console.log("Clicked Outside / Elsewhere");
}
});
We did the following things:
- Selected the HTML element with the class
click-text
.
- Put a mouse down event listener on
document
and set an event handler callback function.
- In the callback function, we are checking if our concerned element — for which we have to detect outside click — contains the element (including itself) which triggered the
mousedown
event (event.target
).
If the element which triggered the mouse down event is either our concerned element or any element which is inside the concerned element, it means we have clicked inside our concerned element.
Let’s click inside and outside of the element in the Codesandbox below, and check the console.
Wrapping DOM Hierarchy Based Detection Logic In A React Component
Great! So far we saw how to use DOM’s Node.contains
API to detect click outside of an element. We can wrap that logic in a React component. We could name our new React component OutsideClickHandler
. Our OutsideClickHandler
component will work like this:
<OutsideClickHandler
onOutsideClick={() => {
console.log("I am called whenever click happens outside of 'AnyOtherReactComponent' component")
}}
>
<AnyOtherReactComponent />
</OutsideClickHandler>
OutsideClickHandler
takes in two props:
children
It could be any valid React children. In the example above we are passing AnyOtherReactComponent
component as OutsideClickHandler
’s child.
onOutsideClick
This function will be called if a click happens anywhere outside of AnyOtherReactComponent
component.
Sounds good so far? Let’s actually start building our OutsideClickHandler
component.
import React from 'react';
class OutsideClickHandler extends React.Component {
render() {
return this.props.children;
}
}
Just a basic React component. So far, we are not doing much with it. We’re just returning the children as they are passed to our OutsideClickHandler
component. Let’s wrap the children
with a div element and attach a React ref to it.
import React, { createRef } from 'react';
class OutsideClickHandler extends React.Component {
wrapperRef = createRef();
render() {
return (
<div ref={this.wrapperRef}>
{this.props.children}
</div>
)
}
}
We’ll use this ref
to get access to the DOM node object associated with the div
element. Using that, we’ll recreate the outside detection logic we made above.
Let’s attach mousedown
event on document inside componentDidMount
React life cycle method, and clean up that event inside componentWillUnmount
React lifecycle method.
class OutsideClickHandler extends React.Component {
componentDidMount() {
document
.addEventListener('mousedown', this.handleClickOutside);
}
componentWillUnmount(){
document
.removeEventListener('mousedown', this.handleClickOutside);
}
handleClickOutside = (event) => {
// Here, we'll write the same outside click
// detection logic as we used before.
}
}
Now, let’s write the detection code inside handleClickOutside
handler function.
class OutsideClickHandler extends React.Component {
componentDidMount() {
document
.addEventListener('mousedown', this.handleClickOutside);
}
componentWillUnmount(){
document
.removeEventListener('mousedown', this.handleClickOutside);
}
handleClickOutside = (event) => {
if (
this.wrapperRef.current &&
!this.wrapperRef.current.contains(event.target)
) {
this.props.onOutsideClick();
}
}
}
The logic inside handleClickOutside
method says the following:
If the DOM node that was clicked (event.target
) was neither our container div (this.wrapperRef.current
) nor was it any node inside of it (!this.wrapperRef.current.contains(event.target)
), we call the onOutsideClick
prop.
This should work in the same way as the outside click detection had worked before. Let’s try clicking outside of the grey text element in the codesandbox below, and observe the console:
The Problem With DOM Hierarchy Based Outside Click Detection Logic
But there’s one problem. Our React component doesn’t work if any of its children are rendered in a React portal.
But what are React portals?
“Portals provide a first-class way to render children into a DOM node that exists outside the DOM hierarchy of the parent component.”
— React docs for portals
In the image above, you can see that though Tooltip
React component is a child of Container
React component, if we inspect the DOM we find that Tooltip DOM node actually resides in a completely separate DOM structure i.e. it’s not inside the Container DOM node.
The problem is that in our outside detection logic so far, we are assuming that the children of OutsideClickHandler
will be its direct descendants in the DOM tree. Which is not the case for React portals. If children of our component render in a React portal — which is to say they render in a separate DOM node which is outside the hierarchy of our container div
in which our OutsideClickHandler
component renders its children — then the Node.contains
logic fails.
How would it fail though? If you’d try to click on the children of our OutsideClickHandler
component — which renders in a separate DOM node using React portals — our component will register an outside click, which it shouldn’t. See for yourself:
Using
Node.contains
to detect outside click of React component gives wrong result for children rendered in a React portal. (Large preview)
Try it out:
Even though the popover that opens on clicking the button, is a child of OutsideClickHandler component, it fails to detect that it isn’t outside of it, and closes it down when it’s clicked.
Using Class Instance Property And Event Delegation To Detect Outside Click
So what could be the solution? We surely can’t rely on DOM to tell us if the click is happening outside anywhere. We’ll have to do something with JavaScript by rewriting out OutsideClickHandler implementation.
Let’s start with a blank slate. So at this moment OutsideClickHandler is an empty React class.
The crux of correctly detecting outside click is:
- To not rely on DOM structure.
- To store the ‘clicked’ state somewhere in the JavaScript code.
For this event delegation will come to our aid. Let’s take an example of the same button and popover example we saw above in the GIF above.
We have two children of our OutsideClickHandler function. A button and a popover — which gets rendered in a portal outside of the DOM hierarchy of OutsideClickHandler, on button click, like so:
When either of our children are clicked we set a variable clickCaptured
to true
. If anything outside of them is clicked, the value of clickCaptured
will remain false
.
We will store clickCaptured
’s value in:
- A class instance property, if you are using a class react component.
- A ref, if you are using a functional React component.
We aren’t using React state to store clickCaptured
’s value because we aren’t rendering anything based off of this clickCaptured
data. The purpose of clickCaptured
is ephemeral and ends as soon as we’ve detected if the click has happened inside or outside.
Let’s seee in the image below the logic for setting clickCaptured
:
Whenever a click happens anywhere, it bubbles up in React by default. It’ll reach to the document
eventually.
When the click reaches document
, there are two things that might have happened:
clickCaptured
will be true, if children where clicked.
clickCaptured
will be false, if anywhere outside of them was clicked.
In the document’s event listener we will do two things now:
- If
clickCaptured
is true, we fire an outside click handler that the user of OutsideClickHandler might have given us through a prop.
- We reset
clickCaptured
to false, so that we are ready for another click detection.
Let’s translate this into code.
import React from 'react'
class OutsideClickHandler extends React.Component {
clickCaptured = false;
render() {
if ( typeof this.props.children === 'function' ) {
return this.props.children(this.getProps())
}
return this.renderComponent()
}
}
We have the following things:
- set initial value of
clickCaptured
instance property to false
.
- In the
render
method, we check if children
prop is a function. If it is, we call it and pass it all the props we want to give it by calling getProps
class method. We haven’t implemented getProps
just yet.
- If the
children
prop is not a function, we call renderComponent
method. Let’s implement this method now.
class OutsideClickHandler extends React.Component {
renderComponent() {
return React.createElement(
this.props.component || 'span',
this.getProps(),
this.props.children
)
}
}
Since we aren’t using JSX, we are directly using React’s createElement API to wrap our children in either this.props.component
or a span
. this.props.component
can be a React component or any of the HTML element’s tag name like ‘div’, ‘section’, etc. We pass all the props that we want to pass to our newly created element by calling getProps
class method as the second argument.
Let’s write the getProps
method now:
class OutsideClickHandler extends React.Component {
getProps() {
return {
onMouseDown: this.innerClick,
onTouchStart: this.innerClick
};
}
}
Our newly created React element, will have the following props passed down to it: onMouseDown
and onTouchStart
for touch devices. Both of their values is the innerClick
class method.
class OutsideClickHandler extends React.Component {
innerClick = () => {
this.clickCaptured = true;
}
}
If our new React component or anything inside of it — which could be a React portal — is clicked, we set the clickCaptured
class instance property to true. Now, let’s add the mousedown
and touchstart
events to the document, so that we can capture the event that is bubbling up from below.
class OutsideClickHandler extends React.Component {
componentDidMount(){
document.addEventListener('mousedown', this.documentClick);
document.addEventListener('touchstart', this.documentClick);
}
componentWillUnmount(){
document.removeEventListener('mousedown', this.documentClick);
document.removeEventListener('touchstart', this.documentClick);
}
documentClick = (event) => {
if (!this.clickCaptured && this.props.onClickOutside) {
this.props.onClickOutside(event);
}
this.clickCaptured = false;
};
}
In the document mousedown and touchstart event handlers, we are checking if clickCaptured
is falsy.
clickCaptured
would only be true
if children of our React component would have been clicked.
- If anything else would have been clicked
clickCaptured
would be false
, and we’d know that outside click has happened.
If clickCaptured
is falsy, we’ll call the onClickOutside method passed down in a prop to our OutsideClickHandler component.
That’s it! Let’s confirm that if we click inside the popover it doesn’t get closed now, as it was before:
Using event delegation logic correctly detects outside click, even if children are rendered in a React portal. (Large preview)
Let’s try it out:
Wonderful!
Outside Focus Detection
Now let’s take a step further. Let’s also add functionality to detect when focus has shifted outside of a React component. It’s going to be very similar implementation as we’ve done with click detection. Let’s write the code.
class OutsideClickHandler extends React.Component {
focusCaptured = false // 1. to add this
innerFocus = () => {
this.focusCaptured = true;
}
componentDidMount(){
document.addEventListener('mousedown', this.documentClick);
document.addEventListener('touchstart', this.documentClick);
document.addEventListener('focusin', this.documentFocus);
}
componentWillUnmount(){
document.removeEventListener('mousedown', this.documentClick);
document.removeEventListener('touchstart', this.documentClick);
document.removeEventListener('focusin', this.documentFocus);
}
documentFocus = (event) => {
if (!this.focusCaptured && this.props.onFocusOutside) {
this.props.onFocusOutside(event);
}
this.focusCaptured = false;
};
// 2. to indent the following piece of code
// 3. This piece of code doesn’t get copied on clipboard on clicking the ‘copy’ button
getProps() { return { onMouseDown: this.innerClick, onTouchStart: this.innerClick, onFocus: this.innerFocus }; }
Everything’s added mostly in the same fashion, except for one thing. You might have noticed that though we are adding an onFocus
react event handler on our children, we are setting a focusin
event listener to our document. Why not a focus
event you say? Because, 🥁🥁🥁, Starting from v17, React now maps onFocus
React event to focusin
native event internally.
In case you are using v16 or before, instead of adding a focusin
event handler to the document, you’ll have to add a focus
event in capture phase instead. So that’ll be:
document.addEventListener('focus', this.documentFocus, true);
Why in capture phase you might ask? Because as weird as it is, focus event doesn’t bubble up.
Since I’m using v17 in all my examples, I’m going to go ahead use the former. Let’s see what we have here:
React Foco component correctly detecting outside click and focus by using event delegation detection logic. (Large preview)
Let’s try it out ourselves, try clicking inside and outside of the pink background. Also use tab and shift + tab keys ( in chrome, firefox, edge ) or Opt/Alt + Tab and Opt/Alt + Shift + Tab ( in Safari ) to toggle focussing between inner and outer button and see how focus status changes.
Conclusion
In this article, we learned that the most straightforward way to detect a click outside of a DOM node in JavaScript is by using Node.contains
DOM API. I explained the importance of knowing why using the same method to detect clicks outside of a React component doesn’t work when the React component has children which render in a React portal. Also, now you know how to use a class instance property alongside an event delegation to correctly detect whether a click happened outside of a React component, as well as how to extend the same detection technique to outside focus detection of a React component with the focusin
event caveat.
Related Resources
- React Foco Github Repository
- mdn documentation for
Node.contains
DOM api
- React docs for portals
- React
createElement
API
- React Github codebase Pull Request for mapping
onFocus
and onBlur
methods to internally use focusin
and focusout
native events.
- Delegating Focus and Blur events