17

I am finding that I am reusing behaviour across an app that when a user clicks outside an element I can hide it.

With the introduction of hooks is this something I could put in a hook and share across components to save me writing the same logic in every component?

I have implemented it once in a component as follows.

const Dropdown = () => {
    const [isDropdownVisible, setIsDropdownVisible] = useState(false);   
    const wrapperRef = useRef<HTMLDivElement>(null);

    const handleHideDropdown = (event: KeyboardEvent) => {
        if (event.key === 'Escape') {
            setIsDropdownVisible(false);
        }
    };

    const handleClickOutside = (event: Event) => {
        if (
            wrapperRef.current &&
            !wrapperRef.current.contains(event.target as Node)
        ) {
            setIsDropdownVisible(false);
        }
    };

    useEffect(() => {
        document.addEventListener('keydown', handleHideDropdown, true);
        document.addEventListener('click', handleClickOutside, true);
        return () => {
            document.removeEventListener('keydown', handleHideDropdown, true);
            document.removeEventListener('click', handleClickOutside, true);
        };
    });

    return(
       <DropdownWrapper ref={wrapperRef}>
         <p>Dropdown</p>
       </DropdownWrapper>
    );
}
0

2 Answers 2

57

This is possible.

You can create a reusable hook called useComponentVisible

import { useState, useEffect, useRef } from 'react';

export default function useComponentVisible(initialIsVisible) {
    const [isComponentVisible, setIsComponentVisible] = useState(initialIsVisible);
    const ref = useRef<HTMLDivElement>(null);

    const handleHideDropdown = (event: KeyboardEvent) => {
        if (event.key === 'Escape') {
            setIsComponentVisible(false);
        }
    };

    const handleClickOutside = (event: Event) => {
        if (ref.current && !ref.current.contains(event.target as Node)) {
            setIsComponentVisible(false);
        }
    };

    useEffect(() => {
        document.addEventListener('keydown', handleHideDropdown, true);
        document.addEventListener('click', handleClickOutside, true);
        return () => {
            document.removeEventListener('keydown', handleHideDropdown, true);
            document.removeEventListener('click', handleClickOutside, true);
        };
    });

    return { ref, isComponentVisible, setIsComponentVisible };
}

Then in the component you wish to add the functionality to do the following:

const DropDown = () => {

    const { ref, isComponentVisible } = useComponentVisible(true);

    return (
       <div ref={ref}>
          {isComponentVisible && (<p>Going into Hiding</p>)}
       </div>
    );

}

Find a codesandbox example here.

Sign up to request clarification or add additional context in comments.

2 Comments

This works like a charm. Thanks a lot. What about if we want to open and close the dropdown using a external component like a button? How do we handle this? Because this external component does not have access to the inner setIscomponentVisible function
I wonder why you decided to set the handlers on the capturing phase and not on the usual bubbling phase of the events.
3

Well, after struggling this for a bit, I have come to the next workarround, IN ADITION to what Paul Fitzgerald did, and having in count that my answer includes transitions too

First, I want my dropdown to be closed on ESCAPE key event and mouse click outside. To avoid creating a useEffect per event, I ended with a helper function:

//useDocumentEvent.js

import { useEffect } from 'react'
export const useDocumentEvent = (events) => {
  useEffect(
    () => {
      events.forEach((event) => {
        document.addEventListener(event.type, event.callback)
      })
      return () =>
        events.forEach((event) => {
          document.removeEventListener(event.type, event.callback)
        })
    },
    [events]
  )
}

After that, useDropdown hook that brings all the desired functionality:

//useDropdown.js

import { useCallback, useState, useRef } from 'react'
import { useDocumentEvent } from './useDocumentEvent'

/**
 * Functions which performs a click outside event listener
 * @param {*} initialState initialState of the dropdown
 * @param {*} onAfterClose some extra function call to do after closing dropdown
 */
export const useDropdown = (initialState = false, onAfterClose = null) => {
  const ref = useRef(null)
  const [isOpen, setIsOpen] = useState(initialState)

  const handleClickOutside = useCallback(
    (event) => {
      if (ref.current && ref.current.contains(event.target)) {
        return
      }
      setIsOpen(false)
      onAfterClose && onAfterClose()
    },
    [ref, onAfterClose]
  )

  const handleHideDropdown = useCallback(
    (event) => {
      if (event.key === 'Escape') {
        setIsOpen(false)
        onAfterClose && onAfterClose()
      }
    },
    [onAfterClose]
  )

  useDocumentEvent([
    { type: 'click', callback: handleClickOutside },
    { type: 'keydown', callback: handleHideDropdown },
  ])

  return [ref, isOpen, setIsOpen]
}

Finally, to use this(it has some emotion styling):

//Dropdown.js
import React, { useState, useEffect } from 'react'
import styled from '@emotion/styled'

import { COLOR } from 'constants/styles'
import { useDropdown } from 'hooks/useDropdown'
import { Button } from 'components/Button'

const Dropdown = ({ children, closeText, openText, ...rest }) => {
  const [dropdownRef, isOpen, setIsOpen] = useDropdown()
  const [inner, setInner] = useState(false)
  const [disabled, setDisabled] = useState(false)
  const timeout = 150
  useEffect(() => {
    if (isOpen) {
      setInner(true)
    } else {
      setDisabled(true)
      setTimeout(() => {
        setDisabled(false)
        setInner(false)
      }, timeout + 10)
    }
  }, [isOpen])
  return (
    <div style={{ position: 'relative' }} ref={dropdownRef}>
      <Button onClick={() => setIsOpen(!isOpen)} disabled={disabled}>
        {isOpen ? closeText || 'Close' : openText || 'Open'}
      </Button>
      <DropdownContainer timeout={timeout} isVisible={isOpen} {...rest}>
        {inner && children}
      </DropdownContainer>
    </div>
  )
}

const DropdownContainer = styled.div(
  {
    position: 'absolute',
    backgroundColor: COLOR.light,
    color: COLOR.dark,
    borderRadius: '2px',
    width: 400,
    boxShadow: '0px 0px 2px 0px rgba(0,0,0,0.5)',
    zIndex: 1,
    overflow: 'hidden',
    right: 0,
  },
  (props) => ({
    transition: props.isVisible
      ? `all 700ms ease-in-out`
      : `all ${props.timeout}ms ease-in-out`,
    maxHeight: props.isVisible ? props.maxHeight || 300 : 0,
  })
)

export { Dropdown }

And, to use it, simply:

//.... your code
<Dropdown>
  <Whatever.Needs.To.Be.Rendered />
</Dropdown>
//... more code

Final result of my use case

Credits to this solution are for previous answer here, this entry in medium and this article.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.