← Back
dom dom-events react typescript

Drop down menu with React

Photo by Dmitriy Adamenko

Photo by Dmitriy Adamenko

I've just written a drop down menu in react, and I've learned a couple of things. Probably the most important:

TL;DR: beware of using hover events in touch devices

But there's more.

What we are going to build #

For this post, we will write drop down menu in React. I'm going to focus into interaction issues (calculate the styles to position in the right place is topic for another post)

A dropdown menu

Simple(st) dropdown menu #

First, we need to define the DropDownMenu component and toggle the overlayed panel when clicking on it:

DropDownMenu.tsx

import React, { useState } from "react";

type Props = {
label: string;
className?: string;
};

const DropDownMenu: React.FC<Props> = ({ label, className, children }) => {
const [isVisible, setVisible] = useState(false);

const toggleVisibility = () => {
setVisible(!isVisible);
};

const overlayStyles = {
// calculate the appropriate styles to position the overlay div
// out of scope of this post
};

return (
<>
<button onClick={toggleVisibility}>{label}</button>
{isVisible && (
<div className={className} style={overlayStyles}>
{children}
</div>
)}
</>
);
};

Astute readers will notice that children prop comes out of the box with React.FC annotation.

The usage is quite straigthforward:

App.tsx:

const App: React.FC = () => {
const [value, setValue] = useState("Open the menu an select a nunber");

return (
<div>
<h1>Dropdown menu example</h1>
<h2>{value}</h2>
<DropdownMenu label="Open menu">
<li>
<button onClick={() => setValue("1")}>One</button>
</li>
<li>
<button onClick={() => setValue("2")}>Two</button>
</li>
<li>
<button onClick={() => setValue("3")}>Three</button>
</li>
</DropdownMenu>
</div>
);
};

First requirement: make the menu closes when clicking an menu item #

This is easy, too. So easy that maybe you're tempted to do something more complex. At least, that happened to me.

Just add a listener to the menu container:

DropDownMenu.tsx {8}:

return (
<>
<button onClick={toggleVisibility}>{label}</button>
{isVisible && (
<div
className={className}
style={overlayStyles}
onClick={() => setVisible(false)}
>

{children}
</div>
)}
</>
);

Second requirement: closes when clicking outside the menu #

Ok. Things got interesting. Checking outside clicks implies:

Add DOM event listeners #

Normally you don't need to add or remove listeners to the DOM directly (it's a react antipattern). But one exception of this rule is when need to detect events in DOM elements not created by react, like... the window:

DropDownMenu.tsx:

function handleClickOutside(event: MouseEvent) {
console.log("Clicked outside!");
}

useEffect(() => {
if (isVisible) {
window.addEventListener("click", handleClickOutside);
return () => window.removeEventListener("click", handleClickOutside);
} else {
window.removeEventListener("click", handleClickOutside);
}
}, [isVisible]);

Basically we add the listener when the menu is visible (line 7) and ensures the listener is removed if the component is ummounted, by returning a callback (line 8). Also, we remove the callback (if any) when the overlay closes (line 10).

It's important to notice that we need to add the isVisible value as dependency (line 12) to ensure the effect is called when isVisible changes (in fact, we have another dependency: just continue reading)

Detect click target #

For the second part (detect the click) we need to interact with the created DOM elements that, in react, means working with references.

We'll use the useRef hook to create the rerefences and store both the button and overlay DOM elements.

Basically, a reference is an object { current: ? } where current is filled with some value. Although it can contain any value, it's normally used to store DOM references. Notice that we need to specify the type of the value (when using Typescript)

To setup the references we will:

DropDownMenu.tsx {1,2,8,13}:

const buttonRef = useRef<HTMLButtonElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);

// ... more stuff here

return (
<>
<button ref={buttonRef} onClick={toggleVisibility}>
{label}
</button>
{isVisible && (
<div
ref={overlayRef}
className={className}
style={overlayStyles}
onClick={() => setVisible(false)}
>

{children}
</div>
)}
</>
);

By using the ref attribute, we're telling react to fill the current value with the DOM element when they are created.

Now we can access the DOM elements, we can write the handleClickOutside function:

DropDownMenu.tsx {7, 8}:

function handleClickOutside(event: MouseEvent) {
if (
buttonRef.current &&
overlayRef.current &&
!buttonRef.current.contains(event.target as Node) &&
!overlayRef.current.contains(event.target as Node)
) {
setVisible(false);
}
}

First we need to check if both references are set, and then check if the target is contained if them. Notice that, since react uses synthetic events not real ones, we need to cast the target to a Node type in order to pass it to the contains method.

To learn more about this problem read this or this stackoverflow question

Understand useEffect dependencies #

The code above almost work. But there's a problem with the useEffect effect. We added the isVisible dependency to ensure the effect is called when the menu visibility changes. That's ok... but not enough.

The effect has another dependency: the handleClickOutside function. Basically, if inside an effect (or any hook) we use any state (or function that uses any state) you need to add it to dependencies. Here's a good but long article that explains why.

So basically we need to add the dependency (line 8):

DropDownMenu.tsx {8}:

useEffect(() => {
if (isVisible) {
window.addEventListener("click", handleClickOutside);
return () => window.removeEventListener("click", handleClickOutside);
} else {
window.removeEventListener("click", handleClickOutside);
}
}, [isVisible, handleClickOutside]);

But you know what: the function handleClickOutside is created on every render of the component. That means that the effect will be called everytime the component is rendered, and that's not what we want.

For solve it, we need to use the useCallback hook, that memoizes function:

DropDownMenu.tsx {8}:

const handleClickOutside = useCallback(
(event: MouseEvent) => {
if (
buttonRef.current &&
overlayRef.current &&
!buttonRef.current.contains(event.target as Node) &&
!overlayRef.current.contains(event.target as Node)
) {
setVisible(false);
}
},
[setVisible, buttonRef.current, overlayRef.current]
);

Note that all hooks, including useCallback, needs an array of dependencies in order to work (without bugs).

Final requirement: open on hover #

Ok, let's asume that we are asked to open the menu on hover. Well, that's easy, just add a handler to the mouse enter event:

DropDownMenu.tsx {8}:

return (
<button
ref={buttonRef}
onClick={toggleVisibility}
onMouseEnter={() => setVisible(true)}
>

...
</button>
);

Well... this works on desktop. But using a mobile (or any touch device), when clicking on the button, the menu doesn't show up!

To know why, first thing to know is that mouse enter and mouse leave events are simulated in mobile enviroments. They are fired just after touchend event. And then, at the end, the click event is fired.

So, let's understand the problem: when clicking over the button in a mobile device, the following will occur:

We need to prevent mouseEnter handler to work when working in a mobile device. And what's the most reliable way to know it's a mobile device (in this context)? If a touch start event is fired, then we know that mouse enter needs to be disabled.

Let's write it:

DropDownMenu.tsx {7,10}:

const [isHoverEnabled, setHoverEnabled] = useState(true);

return (
<button
ref={buttonRef}
onClick={toggleVisibility}
onTouchStart={() => {
setHoverEnabled(false);
}}

onMouseEnter={() => {
if (isHoverEnabled) {
setVisible(true);
}
}}

>

{label}
</button>
);

Summary #

As you can see, a working overlay has some hidden complexities. Some comes from the browser (the mouse enter problem), some comes from react (accessing DOM thought references) and some from using Typescript:

You can see the working example here: https://codesandbox.io/s/elegant-booth-jqso0