How to Close a dropdown when clicking anywhere outside in React (Simple Way)

How to Close a dropdown when clicking anywhere outside in React (Simple Way)

Introduction👋

When I was working with dropdowns, I was using useState() to check and manage the state of the dropdown, so that I can toggle between its open and closed state it was working fine but then I found a bug. The bug was that when we click on anywhere outside the dropdown then it should close it and this is a default behavior we see with any dropdowns, right? but the problem was like no event listener will fire the event when we click outside of that particular element. So for this problem, I've got a solution that is very simple to implement. I've read other blogs but literally, those solutions are way more complex than you think, I thought it's gonna be very difficult but somehow with some AI tools like chatGPT, we got a better and simple way to do so.

Implementation🐱‍👤

The solution is that we need to create two references for our div, the first one is gonna be the content of our dropdown or list of items whose view we want to toggle and the second is gonna be the selected value component. Next, we'll use a click-event listener to track whether the click is outside the div or on the div and based on that we'll toggle the state of our dropdown.

Step 1️⃣: Let's see my current code which is not handling outside clicks

This is a simple dropdown, I'm using React with tailwindCSS to implement the dropdown.

import React, { useState} from "react";

export default function dropDown({ options }) { // options is a list of strings like ['option1','option2','option3']
  const [isOpen, setIsOpen] = useState(false); // state to track whether the dropdown is open or not
  const [selected, setSelected] = useState(options[0]); // to store selected value

  return (
    <div className="relative">
  {/* This is the first div that contains the selected value and button */}
      <div
        className="inline-flex items-center overflow-hidden rounded-md border bg-white"
      >
        {options?.length > 0 && (
          <p
            className="cursor-pointer border-e px-4 py-2 text-sm/none text-gray-600 hover:bg-gray-50 hover:text-gray-700"
            onClick={() => {
              setIsOpen((pre) => !pre);
            }}
          >
            {selected}
          </p>
        )}

        <button
          className="h-full p-2 text-gray-600 hover:bg-gray-50 hover:text-gray-700"
          onClick={() => {
            setIsOpen((pre) => !pre);
          }}
        >
          <svg
            xmlns="http://www.w3.org/2000/svg"
            className="h-4 w-4"
            viewBox="0 0 20 20"
            fill="currentColor"
            style={{ transform: isOpen ? "rotate(180deg)" : "" }}
          >
            <path
              fillRule="evenodd"
              d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
              clipRule="evenodd"
            />
          </svg>
        </button>
      </div>
 {/* This is the second div that contains the options */}
      {isOpen && (
        <div
          className="absolute start-0 z-10 mt-2 w-56 rounded-md border border-gray-100 bg-white shadow-lg"
          role="menu"
        >
          <div className="p-2">
            {options?.map((el) => (
              <p
                className="cursor-pointer block rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-50 hover:text-gray-700"
                role="menuitem"
                onClick={() => {
                  setSelected(el);
                  setIsOpen((pre) => !pre);
                }}
              >
                {el}
              </p>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

Step 2️⃣: Create two references to those divs whose click events we wanna listen to using useRef() hook and connect

const elementRef = useRef(null);  // Reference to your dropdown content div
const excludedRef = useRef(null);  // Refernce to other divs that you want to exclude as an outside div like the selected value div.
// you add more excludedRefs like excludedRef1, excludedRef2, etc...

{/* THIS IS OUR FIRST DIV FROM THE ABOVE CODE */}
      <div
        ref={excludedRef}
        className="inline-flex items-center overflow-hidden rounded-md border bg-white"
      ></div

{/* THIS IS OUR SECOND DIV FROM THE ABOVE CODE */}

        <div
          ref={elementRef}
          className="absolute start-0 z-10 mt-2 w-56 rounded-md border border-gray-100 bg-white shadow-lg"
          role="menu"
        > </div>

Here we have created two references. The elementRef will hold the reference to the div that holds the options in your dropdown. The excludedRef will hold the reference to the div that you want to not consider as an outside element. I know the second reference is quite confusing but I'll explain the significance of this reference at the end of the blog.

Step 3️⃣: Add a click event to modify the state of the Dropdown in useEffect() hook

useEffect(() => {
    const handleClick = (event) => {

// using null safety cause sometimes its not able to find the element
      if (
        elementRef.current &&
        !elementRef.current?.contains(event.target) && 
        !excludedRef.current?.contains(event.target)
      ) {
        // here we're setting the state to false so it will close the dropdown
        setIsOpen(false);
      }
    };

    // Adding click event listener which will get triggered on every click
    document.addEventListener("click", handleClick);

    // Clean up function to remove the event listener when the element is getting unmounted from the dom
    return () => {  
      document.removeEventListener("click", handleClick);
    };
  }, []);

Now, let me explain the if check in the above code. Three conditions need to be fulfilled then and only the block will run. The first condition says the dropdown options list should exist. The second condition says the element that we're clicking on should not be dropdown options and the third condition says the element that we're clicking on should not be the excluded divs (we've added refs on those divs that we don't want to consider as an outside element).

The reason we added excluded divs is that like if only add the condition on the options div then it will consider every div as an outside div, even the selected field in the dropdown or the button of the dropdown that we use to open the options in the dropdown. If the button or selected field in the dropdown is being considered as an outside element then the drop will never open cause in our event function, we're making the open state false whenever we click on any outside element. So it will become a bug. I got the same issue, that's why we should not consider the selected field or button as an outside element.

The Complete Code 👨‍💻

import React, { useState, useEffect, useRef } from "react";

export default function dropDown({ options }) {
  const [isOpen, setIsOpen] = useState(false);
  const [selected, setSelected] = useState(options[0]);
  const elementRef = useRef(null);
  const excludedRef = useRef(null);

  useEffect(() => {
    const handleClick = (event) => {
      if (
        elementRef.current &&
        !elementRef.current?.contains(event.target) &&
        !excludedRef.current?.contains(event.target)
      ) {
        setIsOpen(false);
      }
    };

    document.addEventListener("click", handleClick);

    return () => {
      document.removeEventListener("click", handleClick);
    };
  }, []);

  return (
    <div className="relative ">
      <div
        ref={excludedRef}
        className="inline-flex items-center overflow-hidden rounded-md border bg-white"
      >
        {options?.length > 0 && (
          <p
            className="cursor-pointer border-e px-4 py-2 text-sm/none text-gray-600 hover:bg-gray-50 hover:text-gray-700"
            onClick={() => {
              setIsOpen((pre) => !pre);
            }}
          >
            {selected}
          </p>
        )}

        <button
          className="h-full p-2 text-gray-600 hover:bg-gray-50 hover:text-gray-700"
          onClick={() => {
            setIsOpen((pre) => !pre);
          }}
        >
          <svg
            xmlns="http://www.w3.org/2000/svg"
            className="h-4 w-4"
            viewBox="0 0 20 20"
            fill="currentColor"
            style={{ transform: isOpen ? "rotate(180deg)" : "" }}
          >
            <path
              fillRule="evenodd"
              d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
              clipRule="evenodd"
            />
          </svg>
        </button>
      </div>

      {isOpen && (
        <div
          className="absolute start-0 z-10 mt-2 w-56 rounded-md border border-gray-100 bg-white shadow-lg"
          role="menu"
          ref={elementRef}
        >
          <div className="p-2">
            {options?.map((el) => (
              <p
                className="cursor-pointer block rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-50 hover:text-gray-700"
                role="menuitem"
                onClick={() => {
                  setSelected(el);
                  setIsOpen((pre) => !pre);
                }}
              >
                {el}
              </p>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

Conclusion 🥱

It's better to use useRef() hook sometimes to track whether the current element is being clicked or not. In our above example, it helped in solving a bug in the Dropdown and there must be many examples like this, just need to explore and practice.

My Github🙃: AryanKuAg

My LinkedIn🙂: Aryan Agrawal

Thanks for Reading🙏.