Creating a custom React Hook for clicking outside the component.

On one of my recent projects at work we had one component that required a way to handle the action of clicking outside of the component, it was a custom Search Select dropdown. We wanted it to close when clicking outside. In this case, the engineer assigned to that component created a useOutsideClick hook, but it was kind of specific to that particular use case and not a universal way to handle this action.

Then comes along another engineer who needed to create a similar way to handle a click outside of their component and the hook didn't work for their use case because it was a nested child element that was being clicked. They ended up importing an npm package called react-outside-click-handler. It's still rather popular with 694,000 weekly downloads as of the time of this writing. The only problem is that it was last updated over 4 years ago.

I decided to dig into the repository and found out that it's a single React Class component written by AirBNB called OutsideClickHandler.jsx. It was written long before the days of React Typescript and Hooks. I decided to take a stab at converting it to a React Hook and it was a lot easier than I thought it would be. I was able to get it working in a matter of minutes using ChatGPT's assistance.

The original component's imports:

import React from 'react'
import PropTypes from 'prop-types'

import { forbidExtraProps } from 'airbnb-prop-types'
import { addEventListener } from 'consolidated-events'
import objectValues from 'object.values'

import contains from 'document.contains'

const DISPLAY = {
  BLOCK: 'block',
  FLEX: 'flex',
  INLINE: 'inline',
  'INLINE-BLOCK': 'inline-block',
  CONTENTS: 'contents',
}

const propTypes = forbidExtraProps({
  children: PropTypes.node.isRequired,
  onOutsideClick: PropTypes.func.isRequired,
  disabled: PropTypes.bool,
  useCapture: PropTypes.bool,
  display: PropTypes.oneOf(objectValues(DISPLAY)),
})

const defaultProps = {
  disabled: false,

  // `useCapture` is set to true by default so that a `stopPropagation` in the
  // children will not prevent all outside click handlers from firing - maja
  useCapture: true,
  display: DISPLAY.BLOCK,
}

We really didn't need PropTypes in this case, so I removed them. I also removed the forbidExtraProps and objectValues imports because they were only used for the PropTypes. I also removed the DISPLAY object because it was only used for the display prop. Lastly I removed the addEventListener and contains imports because they were also outdated packages that were simply for polyfills for older browsers, which we don't need to worry about anymore.

At this point I prompted ChatGPT my copy pasting in the code from the original component and it helped me convert it to a functional React component instead of class based.

import React, { useEffect, useRef } from 'react'

type Display = 'block' | 'flex' | 'inline' | 'inline-block' | 'contents'

interface OutsideClickHandlerProps {
  children: React.ReactNode
  onOutsideClick: (event: MouseEvent) => void
  disabled?: boolean
  useCapture?: boolean
  display?: Display
}

const OutsideClickHandler: React.FC<OutsideClickHandlerProps> = ({
  children,
  onOutsideClick,
  disabled = false,
  useCapture = true,
  display = 'block',
}) => {
  const childNode = useRef<HTMLDivElement>(null)

  const onMouseDown = (e: MouseEvent) => {
    const isDescendantOfRoot =
      childNode.current && childNode.current.contains(e.target as Node)
    if (!isDescendantOfRoot) {
      document.addEventListener('mouseup', onMouseUp, { capture: useCapture })
    }
  }

  const onMouseUp = (e: MouseEvent) => {
    const isDescendantOfRoot =
      childNode.current && childNode.current.contains(e.target as Node)
    document.removeEventListener('mouseup', onMouseUp, { capture: useCapture })

    if (!isDescendantOfRoot) {
      onOutsideClick(e)
    }
  }

  useEffect(() => {
    if (!disabled) {
      document.addEventListener('mousedown', onMouseDown, {
        capture: useCapture,
      })
    }

    return () => {
      document.removeEventListener('mousedown', onMouseDown, {
        capture: useCapture,
      })
      document.removeEventListener('mouseup', onMouseUp, {
        capture: useCapture,
      })
    }
  }, [disabled, useCapture])

  return (
    <div ref={childNode} style={display !== 'block' ? { display } : undefined}>
      {children}
    </div>
  )
}

export default OutsideClickHandler

That was easy! Nice. Now I asked it to turn it into a React Hook that took in the same props as the previous useOutsideClick hook that the first engineer made and it gave me the final result:

import { Dispatch, RefObject, SetStateAction, useEffect } from 'react'

export const useOutsideClick = (
  ref: RefObject<HTMLElement>,
  isOpen: boolean,
  setIsOpen: Dispatch<SetStateAction<boolean>>,
  useCapture = true
) => {
  useEffect(() => {
    const onMouseDown = (e: MouseEvent) => {
      const isDescendantOfRoot =
        ref.current && ref.current.contains(e.target as Node)
      if (!isDescendantOfRoot) {
        document.addEventListener('mouseup', onMouseUp, { capture: useCapture })
      }
    }

    const onMouseUp = (e: MouseEvent) => {
      const isDescendantOfRoot =
        ref.current && ref.current.contains(e.target as Node)
      document.removeEventListener('mouseup', onMouseUp, {
        capture: useCapture,
      })

      if (!isDescendantOfRoot) {
        setIsOpen(false)
      }
    }

    if (isOpen) {
      document.addEventListener('mousedown', onMouseDown, {
        capture: useCapture,
      })
    }

    return () => {
      document.removeEventListener('mousedown', onMouseDown, {
        capture: useCapture,
      })
      document.removeEventListener('mouseup', onMouseUp, {
        capture: useCapture,
      })
    }
  }, [isOpen, useCapture])

  return isOpen
}

In order to use this you would import it like so:

import React, { useRef, useState } from 'react'
import { useOutsideClick } from './src/hooks/useOutsideClick'

const MyComponent: React.FC = () => {
  const [isOpen, setIsOpen] = useState(true)
  const ref = useRef<HTMLDivElement>(null)

  useOutsideClick(ref, isOpen, setIsOpen)

  return <div ref={ref}>{/* Your component content */}</div>
}

export default MyComponent

Hooray! Now we have all the upsides of a single reusable React Hook that works for both use cases and we didn't need to import a four year old package to do it. I hope this helps someone else out there who is trying to figure out how to use make a useOutsideClick hook. If you have any questions or comments please let me know!