Luigi Cavalieri - Coding, Sharing, Blogging

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:

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
6function 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}
26
27export default App;

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
9function 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 (
28        callbackRef.current &&
29        !wrapperRef.current?.contains( event.target as Node )
30      ) {
31        callbackRef.current( event )
32      }
33    };
34
35    window.addEventListener( "mousedown", listenerCallback ); 
36
37    return () => {
38      // It's usually a good idea to remove, here, our event listeners.
39      window.removeEventListener( "mousedown", listenerCallback );
40    };
41  }, [] );
42
43  return { setWrapperRef };
44};
45
46export 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 keep their value between consecutive renderings.
  2. ElementType is a name of my own invention, it is the input type. ElementType 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.

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
6function 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 (
13        outerWrapperRef.current &&
14        outerWrapperRef.current.contains( event.target as Node )
15      ) {
16        setCount( count + 1 );
17      }
18    }
19  });
20
21  return (
22    <main>
23      <div
24        ref={outerWrapperRef}
25        className="outer-wrapper"
26      >
27        <div
28          ref={setWrapperRef}
29          className="box"
30        >
31          {count}
32        </div>
33      </div>
34    </main>
35  );
36}
37
38export default App;

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.

This post was first published onand it got the latest major updates on 13 July 2024.