How to detect a click outside in React and test it properly?

Dmytro Chumak
Dmytro Chumak
6 min read

Have you ever been in the situation when you completely have no clue how to write and test components in React? If yes, you are not alone, let me hug you...

TL;DR

In this article, we're going to learn how to implement a click outside functionality using different React techniques. Besides, we will consider unit testing using Enzyme vs React Testing Library.

Click Outside

In general, detection of outside/inside click is a important functionality used by many UI components. If you ever implemented modal, popup, tooltip or custom select, you might be aware of that feature. Otherwise, open the following example to get a visual tip:

Dependencies

What we will discuss in this article is based on the following packages:

Implementation

To capture a click outside in React, we need to care for the few things:

  1. Track clicks on the page
    The common practice would be to attach an event listener to the document.
  2. Get access to React component as DOM node
    In React world it's possible using Refs.
  3. Distinguish outside click from inside
    Attaching ref to the "inside" component, we can achieve access to its DOM instance. On the other hand, because of the bubbling, an event listener attached to the document can receive all "bubbled" click events of any node on the page and forward it to its handler. In turn, a handler having access to our ref and event can distinguish the click using ref.current.contains.

It might sound complicated, but you will see that it's not. Let's apply the above criteria, considering three different approaches:

import React, { useEffect, useRef, cloneElement, useState } from 'react';
import PropTypes from 'prop-types';
const ClickOutside = ({ children, onClick }) => {
const ref = useRef();
useEffect(() => {
if (!ref?.current) {
return;
}
const handleClickOutside = (e) => {
// highlight-next-line
if (onClick && !ref.current.contains(e.target)) {
onClick(e);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
// highlight-next-line
}, [onClick]);
// highlight-next-line
return <>{cloneElement(children, { ref })}</>;
};
ClickOutside.propTypes = {
children: PropTypes.element.isRequired,
onClick: PropTypes.func.isRequired,
};
export default ClickOutside;
export const Demo = () => {
const [insideCount, setInsideCount] = useState(0);
const [outsideCount, setOutsideCount] = useState(0);
return (
<>
<ClickOutside onClick={() => setOutsideCount((count) => count + 1)}>
<button onClick={() => setInsideCount((count) => count + 1)}>
Click inside
</button>
</ClickOutside>
<hr />
<p>Inside clicks count: {insideCount}</p>
<p>Outside clicks count: {outsideCount}</p>
</>
);
};

FC details

1. You may have noticed that }, [onClick]); looks dangerous. In this relation, you might think of:

onClick as dependency of useEffect? Wait! It might not be performance friendly. In fact, useEffect can be called each time onClick reference changes. That means, it can happen when parent component re-renders.

Okay, that is a valid argument, and I like you aware of that. We can deal with it as well as ignore it.

Deal with it:

  • Change the implementation from }, [onClick]); to }, []). It would be the simplest way how to prevent the useEffect from being called on each re-render.
  • Memoize a parent component callback by using the useCallback React hook. As a result, the onClick prop will be pointing to the same reference. It stops the useEffect to be executed. I think this is the most flexible solution because there can be a case when you need to "replace" a callback.
  • Do something crazy.

Ignore:

We should care about performance issues when a problem can be notable. React can handle this code very fast, even without using useCallback or setting no dependency([]). Therefore, blindly fixing stuff might be a premature optimization, which is the root of all evil.

Both options are legal, and the decision is up to you.

2. Also notice, the custom children component in <ClickOutside /> should use forwardRef. The reason is this part of code: cloneElement(children, { ref }). Follow the example:

const FancyButton = React.forwardRef((props, ref) => (
<button ref={ref} onClick={props.onClick}>
{props.children}
</button>
));
<ClickOutside onClick={handleClickOutside}>
<FancyButton onClick={handleClickInside}>Click inside</FancyButton>
</ClickOutside>;

Now you know how to implement the feature using different React techniques.

P.S. Open codesandbox examples by clicking to the "pen" button to see how each example works.

Unit tests

Finally, you have reached this point. If you use Enzyme I have something interesting for you. Let's randomly take the functional component to be our lab rat.

Test cases

Well, as developers we should be concerned with the following test cases:

  1. should render children
  2. should call onClick() when clicking outside
  3. should NOT call onClick() when clicking inside
  4. should NOT call onClick() when child is null
  5. should NOT call onClick() when component umnount

The good point is we can test any implementation from above using the same test cases. The exception the only custom hook, which has no children.

Testing: Enzyme vs React Testing Library

Let's consider second and third cases as the most important(all other test cases you find on codesandbox). Deliberately, I want to draw a line between Enzyme and React Testing Library to show which one is better for our case.

import React from 'react';
import { configure, mount, shallow } from 'enzyme';
import ClickOutside from '../Demo';
// highlight-next-line
export const createDocumentListenersMock = () => {
const listeners = {};
const handler = (domEl, event) => listeners?.[event]?.({ target: domEl });
document.addEventListener = jest.fn((event, cb) => {
listeners[event] = cb;
});
document.removeEventListener = jest.fn(event => {
delete listeners[event];
});
return {
mouseDown: domEl => handler(domEl, 'mousedown'),
click: domEl => handler(domEl, 'click'),
};
};
describe('<ClickOutside />', () => {
const children = <button type="button">Button Text</button>;
// highlight-next-line
const fireEvent = createDocumentListenersMock();
it('should call onClick() when clicking outside', () => {
const onClick = jest.fn();
mount(<ClickOutside onClick={onClick}>{children}</ClickOutside>);
// highlight-next-line
fireEvent.mouseDown(document.body);
expect(onClick).toHaveBeenCalledTimes(1);
});
it('should NOT call onClick() when clicking inside', () => {
const onClick = jest.fn();
const wrapper = mount(
<ClickOutside onClick={onClick}>{children}</ClickOutside>
);
// highlight-next-line
fireEvent.mouseDown(wrapper.find('button').instance());
expect(onClick).not.toHaveBeenCalled();
});

Enzyme testing details

Testing with Enzyme sometimes could be weird. This is that case.

There is no out-of-the-box feature to test addEventListener related features in Enzyme (read more), but we can handle it on our own:

First of all, we create a helper mock (createDocumentListenersMock). It collects event handlers, which the document receives on the component mount. Our mock helper returns a map with handles per event. Such a map we can semantically call fireEvent and use it like fireEvent.mouseDown().

Also it needs to be clarified if we should use mount or shallow. The first sentence from the doc makes it clear:

Full DOM rendering is ideal for use cases where you have components that may interact with DOM APIs...

We need to call useEffect and addEventListener in out test, but the only mount can do this, because it uses jsdom under the hood.

Having mock and using mount from Enzyme, now we can simulate events with different target.

Let's consider our tests:

"should call onClick() when clicking outside":

Calling the fireEvent.mouseDown(domEl), we call a component handler defined in our component. The only question is what should be a domEl to simulate a click outside. Well, the document.body is the best candidate for this.

"should NOT call onClick() when clicking inside":

In this case, a domEl would be the button(children component). To get access to the button as DOM element we needs to use wrapper.find().instance().

Conclusions

We've seen how to implement a click outside detection in React, using different techniques. I hope new knowledge can save you from installing an extra npm package.

We also learned how to test this feature with the most popular testing libraries in the React community. Although, Enzyme has shown itself not favorably, it still possible to test such functionality. On the other hand, now we know how much we can benefit from using React Testing Library.

In my opinion, React Testing Library is the best choice today for unit testing. Nevertheless, Enzyme is still a top library to write tests in 2020.

Do you like the article or have some remarks? Let's discuss it!

Join the Newsletter

Subscribe to be up to date by email.
Cool content and no spam.

tridenttridentGlory to Ukraine!