useClickOutside() — A Custom React Hook to Intercept Clicks Falling Outside an Element
A reusable solution based on the React Hook pattern, and spiced with some TypeScript, to intercept click events occurring outside a given element.
Intercepting click events that occur outside a given element is something you might have had already the need to handle, or maybe it's something you need to translate into React code right now, whatever the case, a reusable solution is more often than not a big helping hand in speeding up development and strengthen your app's architecture. Manly for these reasons, today I want to share with you the implementation of useClickOutside()
, a custom React hook spiced with some TypeScript.
First, a Use Case
Before disclosing its implementation, I think that stating with a use case can only be of help in reading the code for the first time. So, our playground is going to be an app whose visible part is made up of just a green rectangle with a number in the middle, the number is a counter that is incremented by one whenever we click outside the containing box:
The App
component where we put the useClickOutside()
hook in action is this:
1import "./App.css";
2import { useState } from "react";
3import useClickOutside from "./hooks/useClickOutside";
4
5
6export default function App() {
7 const [ count, setCount ] = useState( 0 );
8
9 const { setWrapperRef } = useClickOutside<HTMLDivElement>({
10 clickOutsideFn: () => {
11 setCount( count + 1 );
12 }
13 });
14
15 return (
16 <main>
17 <div
18 ref={setWrapperRef}
19 className="box"
20 >
21 {count}
22 </div>
23 </main>
24 );
25}
And for completeness, here is the content of App.css
:
1#root {
2 height: 100%;
3}
4
5main,
6.box {
7 align-items: center;
8 display: flex;
9 justify-content: center;
10}
11
12main {
13 height: 100vh;
14 width: 100%;
15}
16
17.box {
18 border: 4px dashed green;
19 background-color: rgb(51, 160, 51);
20 color: white;
21 font-size: 240%;
22 font-weight: bold;
23 height: 45%;
24 max-height: 10rem;
25 max-width: 15rem;
26 width: 45%;
27}
As you can see, useClickOutside()
has two inputs:
- a type: the type related to the HTML element we want to observe, in our case a
HTMLDivElement
- a configuration object: the mean through which we pass to our custom hook the function that must be called when the click lands outside the observed element
And it returns an object containing only a function: setWrapperRef()
is a setter that must be assigned as-is to the ref
prop of the observed element.
There's really nothing else left to document, the useClickOutside()
hook is very straightforward to use, thus there is no reason whatsoever not to take a look at its implementation. So let's go on!
The Implementation
1import { useRef, useEffect } from "react";
2
3export type ClickOutsideCallback = ( event: MouseEvent ) => void;
4
5interface ConfigProps {
6 clickOutsideFn: ClickOutsideCallback;
7}
8
9export default function useClickOutside<ElementType extends HTMLElement>({
10 clickOutsideFn
11}: ConfigProps ) {
12 const wrapperRef = useRef<ElementType | null>( null );
13 const callbackRef = useRef<ClickOutsideCallback | null>( null );
14
15 const setWrapperRef = ( element: ElementType ) => {
16 wrapperRef.current = element;
17 };
18
19 useEffect(() => {
20 if ( typeof clickOutsideFn === "function" ) {
21 callbackRef.current = clickOutsideFn;
22 }
23 }, [ clickOutsideFn ] );
24
25 useEffect(() => {
26 const listenerCallback = ( event: MouseEvent ) => {
27 if ( !wrapperRef.current?.contains( event.target as Node ) ) {
28 callbackRef.current?.( event )
29 }
30 };
31
32 window.addEventListener( "mousedown", listenerCallback );
33
34 return () => {
35 // It's usually a good idea to remove, here, our event listeners.
36 window.removeEventListener( "mousedown", listenerCallback );
37 };
38 }, [] );
39
40 return { setWrapperRef };
41}
Comments on the Code
The first three things in need of attention are the following:
- The setter we saw in the example, just updates a Ref. Here we use Refs instead of ordinary variables because the former keep their value between consecutive renderings.
- ElementType is a name of my own invention, it is the input type.
ElementType
is constrained to the typeHTMLElement
through the clauseextends
, that's why we could passHTMLDivElement
as input type. In other words,setWrapperRef()
can accept any element whose type extendsHTMLElement
. - The aim of the typecast included in the IF statement present in
listenerCallback()
, is to overcome an inevitable TypeScript error due to the mismatch between the type expected bycontains()
— aNode
— and the type ofevent.target
— aEventTarget
. Apart from the technicalities, in this specific case we can safely fix the type mismatch with a typecast because we know for sure thatevent.target
will always be a React node.
What's the Purpose of callbackRef?
Its purpose is to ensure our custom hook, useClickOutside()
, retains good performance whatever the body of clickOutsideFn()
would include.
I'll explain.
If we ditched callbackRef
altogether, and so the useEffect()
where the Ref is updated, the dependency array of the second useEffect()
couldn't be empty, but it should include clickOutsideFn
...
1function useClickOutside<ElementType extends HTMLElement>({
2 clickOutsideFn
3}: ConfigProps ) {
4 const wrapperRef = useRef<ElementType | null>( null );
5
6 const setWrapperRef = ( element: ElementType ) => {
7 wrapperRef.current = element;
8 };
9
10 useEffect(() => {
11 const listenerCallback = ( event: MouseEvent ) => {
12 if (
13 typeof clickOutsideFn === "function" &&
14 !wrapperRef.current?.contains( event.target as Node )
15 ) {
16 clickOutsideFn( event )
17 }
18 };
19
20 // ...
21 }, [ clickOutsideFn ] );
22
23 return { setWrapperRef };
24};
...thus making the callback of the above useEffect()
to potentially run many times, resulting in the event listener to be repeatedly added and removed without need. In fact, if we go back to the example from which we started, clickOutsideFn()
would change every time count
changes in the App
component, and the above useEffect()
would execute its callback as many times. It might be said: «All right, but if in clickOutsideFn()
we remove the dependency from count
by writing something the like...
1const { setWrapperRef } = useClickOutside<HTMLDivElement>({
2 clickOutsideFn: () => {
3 setCount( value => value + 1 );
4 }
5});
...the problem would vanish!»
Yes, but only in our exemplification. Because we cannot generally assume that the developer who writes clickOutsideFn()
never has the need to access variables which change often, and are defined in the scope of the component where useClickOutside()
is called, like our state variable count
.
Obviously here I'm a bit exasperating the problem of performance, after all the code is minimal, and simplifying it as just discussed would probably worsen the use of resources imperceptibly, but it's also true that if we train good habits even when we are dealing with minor developments like this one, the long-term outcome can only be positive.
Intercepting Clicks Occurring Outside an Element but Inside its Parent
Another use case for useClickOutside()
is the one exemplified below, where the counter is incremented only when the clicks occur on the area outside the observed element (green rectangle) but inside its parent. On the area in red, in other words:
This behaviour is achievable with only a few additional lines of code, and without having to make any changes to the useClickOutside()
hook:
1import "./App.css";
2import { useState, useRef } from "react";
3import useClickOutside from "./hooks/useClickOutside";
4
5
6export default function App() {
7 const outerWrapperRef = useRef<HTMLDivElement>( null );
8 const [ count, setCount ] = useState( 0 );
9
10 const { setWrapperRef } = useClickOutside<HTMLDivElement>({
11 clickOutsideFn: ( event: MouseEvent ) => {
12 if ( outerWrapperRef.current?.contains( event.target as Node ) ) {
13 setCount( count + 1 );
14 }
15 }
16 });
17
18 return (
19 <main>
20 <div
21 ref={outerWrapperRef}
22 className="outer-wrapper"
23 >
24 <div
25 ref={setWrapperRef}
26 className="box"
27 >
28 {count}
29 </div>
30 </div>
31 </main>
32 );
33}
As you can see, there is nothing new in the code just introduced. What we have done here is similar to what we did inside useClickOutside()
. A detail, in particular, allowed us to reach the desired result with minimal new code: the event
object passed as argument to the clickOutsideFn
callback.
I am sure there are other interesting use cases where our useClickOutside()
hook, or a variant of it, can unveil more of its flexibility, but now is up to you to discover them.