Search code examples
javascriptreactjstypescriptreact-hooksuse-ref

React dropdown toggles only once


I am trying to build React Dropdown component using useRef hook and Typescript: It opens correctly and closes if I click toggle button once or click outside of it, but it will closed when I want to open it again. Any ideas ? Is this I am loosing ref referance somehow ?

Here is usage:

https://codesandbox.io/s/react-typescript-obdgs

import React, { useState, useRef, useEffect } from 'react'
import styled from 'styled-components'

interface Props {}

const DropdownMenu: React.FC<Props> = ({ children }) => {
  const [menuOpen, toggleMenu] = useState<boolean>(false)
  const menuContent = useRef<HTMLDivElement>(null)

  useEffect(() => {
    // console.log(menuOpen)
  }, [menuOpen])

  const showMenu = (event: React.MouseEvent) => {
    event.preventDefault()
    toggleMenu(true)
    document.addEventListener('click', closeMenu)
  }

  const closeMenu = (event: MouseEvent) => {
    const el = event.target
    if (menuContent.current) {
      if (el instanceof Node && !menuContent.current.contains(el)) {
        toggleMenu(false)
        document.removeEventListener('click', closeMenu)
      }
    }
  }

  return (
    <div>
      <button
        onClick={(event: React.MouseEvent) => {
          showMenu(event)
        }}
      >
        Open
      </button>
      {menuOpen ? <div ref={menuContent}>{children}</div> : null}
    </div>
  )
}

export default DropdownMenu

Solution

  • If you click the button twice, you will not be able to open it again. If you click outside the button to close, it will work as expected.

    This is probably because your showMenu callback is executed even when the menu is already shown, which results in multiple closeMenu event listeners being attached, which in turn leads to weird behaviour.

    The closeMenu event listener should be created inside an effect, not in the showMenu callback.

    const showMenu = (event: React.MouseEvent) => {
        event.preventDefault()
        toggleMenu(true)
    }
    
    // closeMenu is the same
    const closeMenu = (event: MouseEvent) => {
        const el = event.target
        if (menuContent.current) {
            if (el instanceof Node && !menuContent.current.contains(el)) {
                toggleMenu(false)
                document.removeEventListener('click', closeMenu)
            }
        }
    }
    
    useEffect(() => {
        if (!menuOpen) {
            return
        }
        document.addEventListener('click', closeMenu)
        return () => {
            document.removeEventListener('click', closeMenu)
        }
    }, [menuOpen])
    

    useEffect is really cool - the returned function where the event listener is removed will be called both when menuOpen is changed, and when the component is unmounted. In your previous code, if the component would be unmounted, the event listener would not be removed.