Drop down menu with React
Photo by Dmitriy AdamenkoI'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)
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 a window listener when the menu opens
- When a user click, check it's not inside the panel
- Remove the window listener when the menu closes
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:
- mouseEnter is fired: the menu is shown
- click is fired: the menu is hidden
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:
- In react, we can setup event handlers everywhere we need it. Trust events.
- Adding and removing listeners directly to DOM is almost never required... except when need to listening the whole window. Use effect to setup and teardown.
- Use
useRef
hook to create react references - React uses Synthetic events and the
target
property doesn't have a Node type. It means that, if using Typescript, in some cases you'll need some kind of casting.
You can see the working example here: https://codesandbox.io/s/elegant-booth-jqso0