photo of a cafe menu
Photo by Croissant on Unsplash

Building a Dropdown Menu Component With React Hooks

In this article, you'll create a dropdown menu using React Hooks.

6 min read

Today we are going to be building a React hooks dropdown menu. This type of UI element can be found almost everywhere, and inspiration for this particular one came from Airbnb's header.

Given the title of this post, we'll be using React, but the markup, styles and general technique can be applied anywhere. It also assumes you have at least some knowledge of React hooks.

The Component

First let's identify how we want this component to work. When our trigger button is clicked, we want to display our menu. When that same button is clicked again, or if the user clicks outside of the menu, we toggle it closed.

We'll start by creating a component named DropdownMenu.jsx. The component can only ever be in one of two states: active or inactive, and will be controlled by the useState() hook.

Additionally, we'll want to use a React ref to be able to reference the dropdown menu itself. What we should have so far is:


const DropdownMenu = () => {
  const dropdownRef = useRef(null);
  const [isActive, setIsActive] = useState(false);

  return ();
};

Next, lets create our JSX markup. We want to add a base container that will hold the trigger button and the dropdown. I won't go into much detail on the trigger button itself because it can contain any text/image/etc., but the important part is the onClick event that will update the state from inactive to active.


const DropdownMenu = () => {
  const dropdownRef = useRef(null);
  const [isActive, setIsActive] = useState(false);
  const onClick = () => setIsActive(!isActive);

  return (
    <div className="menu-container">
      <button onClick={onClick} className="menu-trigger">
        <span>User</span>
        <img src="https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/df/df7789f313571604c0e4fb82154f7ee93d9989c6.jpg" alt="User avatar" />
      </button>
    </div>
  );
};

The dropdown menu container will be a nav element and receive a class of active or inactive, depending on the current state. It'll be adjacent to the trigger button but still within the menu-container div.


return (
  <div className="menu-container">
    <button onClick={onClick} className="menu-trigger">
      <span>User</span>
      <img src="https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/df/df7789f313571604c0e4fb82154f7ee93d9989c6.jpg" alt="User avatar" />
    </button>
    <nav ref={dropdownRef} className={`menu ${isActive ? 'active' : 'inactive'}`}>
      <ul>
        <li><a href="/messages">Messages</a></li>
        <li><a href="/trips">Trips</a></li>
        <li><a href="/saved">Saved</a></li>
      </ul>
    </nav>
  </div>
);

Notice the use of the dropdownRef constant. This is what allows us to keep track of the dropdown and we can use it to determine if a user has clicked outside of it. We'll come back to this shortly.

The CSS

We'll mostly focus on the dropdown itself in this section, but the menu trigger button styles will be included for reference at the end.

We want to first start with the base menu-container.


.menu-container {
  position: relative;
}

Next, the dropdown menu itself.


.menu {
  background: #ffffff;
  border-radius: 8px;
  position: absolute;
  top: 60px;
  right: 0;
  width: 300px;
  box-shadow: 0 1px 8px rgba(0, 0, 0, 0.3);
  opacity: 0;
  visibility: hidden;
  transform: translateY(-20px);
  transition: opacity 0.4s ease, transform 0.4s ease, visibility 0.4s;
}

.menu.active {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
}

.menu ul {
  list-style: none;
  padding: 0;
  margin: 0;
}

.menu li {
  border-bottom: 1px solid #dddddd;
}

.menu li a {
  text-decoration: none;
  color: #333333;
  padding: 15px 20px;
  display: block;
}

On the menu element, we add the opacity, translateY and visibility properties so that we can transition the dropdown and hide it visually.

Before our menu becomes active, there's a small negative translateY value set on it. Our active class then sets this value to 0. And since this is a property we want to transition, we get a nice subtle animation.

The remaining styles are specific to the dropdown trigger.


.menu-trigger {
  background: #ffffff;
  border-radius: 90px;
  cursor: pointer;
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 4px 6px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
  border: none;
  vertical-align: middle;
  transition: box-shadow 0.4s ease;
}

.menu-trigger:hover {
  box-shadow: 0 1px 8px rgba(0, 0, 0, 0.3);
}

.menu-trigger span {
  font-weight: 700;
  vertical-align: middle;
  font-size: 14px;
  margin: 0 10px;
}

.menu-trigger img {
  border-radius: 90px;
}

With all of this in place, you should now have a functioning menu that opens and closes with the trigger button.

Closing When Clicking Outside

There's still one problem. The menu can't be closed without clicking the trigger button again, which is not a great user experience. It would be much better if the menu would close if you clicked the trigger OR clicked anywhere else besides inside of the menu.

In order to add this functionality, we need to first add a useEffect hook. This hook will allow us to perform logic when the isActive state changes.


const DropdownMenu = () => {
  const dropdownRef = useRef(null);
  ...

  useEffect(() => {

  }, [isActive]);

  return (...)
}

Once the menu is active, we need a function that will determine if our menu was the element that was clicked on. We'll place it inside of the useEffect.


useEffect(() => {
  const pageClickEvent = (e) => {
    console.log(e);
  };

}, [isActive]);

Now we'll need a way to determine if the user is clicking something on the screen, and only while the menu is currently active. For that, we'll add an event listener and pass it the pageClickEvent function we just created.


useEffect(() => {
  const pageClickEvent = (e) => {
    console.log(e);
  };

  // If the item is active (ie open) then listen for clicks
  if (isActive) {
    window.addEventListener('click', pageClickEvent);
  }

}, [isActive]);

It's important to unset our event listener once the dropdown is closed. To do that, just return a function from useEffect. This is a way to perform any cleanup.


useEffect(() => {
  ...

  return () => {
    window.removeEventListener('click', pageClickEvent);
  }

}, [isActive]);

You should now be seeing event data logged to your console when clicking around the screen when the dropdown is active.

Next, we want to determine if the event target (i.e., the element that was clicked on) is a descendent of the dropdown menu itself. If it is, do nothing. Otherwise, we want to close the dropdown. This is where the dropdownRef constant from earlier becomes relevant. Since a ref is a reference to a DOM element, we're able to determine this. Back to the pageClickEvent function.


const pageClickEvent = (e) => {
  // If the active element exists and is clicked outside of
  if (dropdownRef.current !== null && !dropdownRef.current.contains(e.target)) {
    setIsActive(!isActive);
  }
};

All we need to do is add an if statement that checks for two things. First, check if dropdownRef.current isn't null. When a ref is available, the .current value contains the underlying DOM element. We want to make sure there is an element before further asserting on it.

We also want to use the .contains method and pass it the target element. If our dropdown doesn't contain the element that was clicked (meaning the click was outside the dropdown), then update the state to inactive and close the dropdown.

With this addition in place, the dropdown menu should be functioning exactly as we want it!

Further Abstraction

Let's take what we just did a step further. What if we needed to the reuse the same logic of hiding an element when a user clicks outside of it? Fortunately, writing a custom hook can help us package up this logic and use it elsewhere.

We start by creating a new file named useDetectOutsideClick.js which contains a function of the same name. Our function not only accepts an element to detect the clicks on or outside of the dropdown menu, but it also accepts an initial state value.
Let's then move all of the useState and useEffect logic from above into this new function.


import { useState, useEffect } from 'react';

export const useDetectOutsideClick = (el, initialState) => {
  const [isActive, setIsActive] = useState(initialState);

  useEffect(() => {
    const pageClickEvent = (e) => {
      // If the active element exists and is clicked outside of
      if (el.current !== null && !el.current.contains(e.target)) {
        setIsActive(!isActive);
      }
    };

    // If the item is active (ie open) then listen for clicks
    if (isActive) {
      window.addEventListener('click', pageClickEvent);
    }

    return () => {
      window.removeEventListener('click', pageClickEvent);
    }

  }, [isActive, el]);
}

Next, we need to return something from this new hook. We'll return an array containing the isActive/setIsActive useState pair.


export const useDetectOutsideClick = (el, initialState) => {
  const [isActive, setIsActive] = useState(initialState);
  useEffect(() => { ... }

  return [isActive, setIsActive];
}

This lets us move the state management out of our component and into our hook. It also allows our useEffect logic to manage event listeners.

We'd use this new hook like so:


import { useDetectOutsideClick } = './useDetectOutsideClick/js';

const DropdownMenu = () => {
  const dropdownRef = useRef(null);
  const [isActive, setIsActive] = useDetectOutsideClick(dropdownRef, false);
  const onClick = () => setIsActive(!isActive);

  return (...)
}

We pass the dropdownRef value and an initialState (false in this scenario) to our new hook. No other changes need to be made in our DropdownMenu component for this to work.

Summary

As you can see, with just a few React hooks and some basic styles, we can create a nice looking dropdown menu. Not only that, we now have a React hook that we can reuse in all kinds of different ways!