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:
Popup gif example
Dependencies
What we will discuss in this article is based on the following packages:
- React:
latest
- React-dom:
latest
- Jest:
latest
- React Testing Library:
^10.0.2
- Enzyme:
^3.11.0
- enzyme-adapter-react-16:
^1.15.2
- A cup of coffee:
^2.0.0
Implementation
To capture a click outside in React, we need to care for the few things:
- Track clicks on the page
The common practice would be to attach an event listener to thedocument
. - Get access to React component as DOM node
In React world it's possible using Refs. - Distinguish outside click from inside
Attachingref
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 thedocument
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 ourref
andevent
can distinguish the click usingref.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) => {if (onClick && !ref.current.contains(e.target)) {onClick(e);}};document.addEventListener('mousedown', handleClickOutside);return () => {document.removeEventListener('mousedown', handleClickOutside);};}, [onClick]);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></>);};
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:
- should render children
- should call
onClick()
when clicking outside - should NOT call
onClick()
when clicking inside - should NOT call
onClick()
when child isnull
- should NOT call
onClick()
when componentumnount
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';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>;const fireEvent = createDocumentListenersMock();it('should call onClick() when clicking outside', () => {const onClick = jest.fn();mount(<ClickOutside onClick={onClick}>{children}</ClickOutside>);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>);fireEvent.mouseDown(wrapper.find('button').instance());expect(onClick).not.toHaveBeenCalled();});
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.