Luigi Cavalieri - Authoring Open Source Code

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.

useClickOutside(): A Custom React Hook to Intercept Clicks Falling Outside an 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:

Custom hook use case
Use case where a number is incremented by clicking outside the containing box.

The App component where we put the useClickOutside() hook into action is this:

1/* App.tsx */
2
3import "./App.css";
4import { useState } from "react";
5import useClickOutside from "./hooks/useClickOutside";
6
7
8function App() {
9  const [ count, setCount ] = useState( 0 );
10
11  const { setWrapperRef } = useClickOutside<HTMLDivElement>({
12    clickOutsideFn: () => {
13      setCount( count + 1 );
14    }
15  });
16
17  return (
18    <main>
19     <div
20        ref={setWrapperRef}
21        className="box"
22      >
23        {count}
24      </div>
25    </main>
26  );
27}
28
29export default App;

And the content of App.css is the following:

1/* App.css */
2
3#root {
4  height: 100%;
5}
6
7main {
8  align-items: center;
9  display: flex;
10  height: 100vh;
11  justify-content: center;
12  width: 100%;
13}
14
15.box {
16  align-items: center;
17  display: flex;
18  background-color: green;
19  color: white;
20  font-size: 3rem;
21  font-weight: bold;
22  height: 10rem;
23  justify-content: center;
24  width: 15rem;
25}

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

1/* useClickOutside.ts */
2
3import { useRef, useEffect } from "react"; 
4
5export type ClickOutsideCallback = ( event: MouseEvent ) => void;
6
7interface ConfigProps {
8  clickOutsideFn: ClickOutsideCallback;
9}
10
11function useClickOutside<ElementType extends HTMLElement>({
12  clickOutsideFn
13}: ConfigProps ) {
14  const wrapperRef  = useRef<ElementType | null>( null );
15  const callbackRef = useRef<ClickOutsideCallback | null>( null );
16
17  const setWrapperRef = ( element: ElementType ) => {
18    wrapperRef.current = element;
19  };
20
21  useEffect(() => {
22    if ( typeof clickOutsideFn === "function" ) {
23      callbackRef.current = clickOutsideFn;
24    }
25  }, [ clickOutsideFn ] );
26
27  useEffect(() => {
28    const listenerCallback = ( event: MouseEvent ) => {
29      if (
30        callbackRef.current &&
31        !wrapperRef.current?.contains( event.target as Node )
32      ) {
33        callbackRef.current( event )
34      }
35    };
36
37    window.addEventListener( "mousedown", listenerCallback ); 
38
39    return () => {
40      // It's always a good idea to delete event 
41      // listeners during the unmount.
42      window.removeEventListener( "mousedown", listenerCallback );
43    };
44  }, [] );
45
46  return { setWrapperRef };
47};
48
49export default useClickOutside;

Comments on the Code

The first three things in need of attention are the following:

  1. The setter we saw in the example, just updates a Ref. Here we use Refs instead of ordinary variables because the former don't change between consecutive renderings.
  2. ElementType is a name of my own invention. ElementType is the input type, which is constrained to the type HTMLElement through the clause extends, that's why we could pass HTMLDivElement as input type. In other words, setWrapperRef() can accept any element whose type extends HTMLElement.
  3. 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 by contains() — a Node — and the type of event.target — a EventTarget. Apart from the technicalities, in this specific case we can safely fix the type mismatch with a typecast because we know for sure that event.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.