Wednesday, 3 March, 2021 UTC


Summary

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:
  1. Selected the HTML element with the class click-text.
  2. Put a mouse down event listener on document and set an event handler callback function.
  3. 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:
  1. children
    It could be any valid React children. In the example above we are passing AnyOtherReactComponent component as OutsideClickHandler’s child.
  2. 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:
  1. To not rely on DOM structure.
  2. 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:
  1. A class instance property, if you are using a class react component.
  2. 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:
  1. clickCaptured will be true, if children where clicked.
  2. clickCaptured will be false, if anywhere outside of them was clicked.
In the document’s event listener we will do two things now:
  1. If clickCaptured is true, we fire an outside click handler that the user of OutsideClickHandler might have given us through a prop.
  2. 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:
  1. set initial value of clickCaptured instance property to false.
  2. 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.
  3. 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.
  1. clickCaptured would only be true if children of our React component would have been clicked.
  2. 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

  1. React Foco Github Repository
  2. mdn documentation for Node.contains DOM api
  3. React docs for portals
  4. React createElement API
  5. React Github codebase Pull Request for mapping onFocus and onBlur methods to internally use focusin and focusout native events.
  6. Delegating Focus and Blur events