Dismissing a React Dropdown Menu by Clicking Outside its Container

Byte-Sized Series 1, Part 2

In Part 1 of our Byte-Size Series 1, we talked about how to implement a primitive React dropdown component. Today, we’re going to introduce a new requirement of dismissing the dropdown menu by clicking outside its container and some potential options.

Here’s how the basic dropdown looks at the moment:

Here are some attempts in fulfilling that requirement…

Attempt 1: Try onBlur callback

If you’re new in frontend and React as I was a few months ago, you may think the onBlur callback may be the solution since it alerts to something happening outside the component:

handleBlur = (e) => {
console.log('on blur')
this.setState({dropdownVisible: false})
}
renderDropdownMenu() {
return (
<div className='dropdown-body' onBlur={this.handleBlur}>
<div>
<input type='checkbox'/><span>option 1</span>
</div>
<div>
<input type='checkbox'/><span>option 2</span>
</div>
</div>
)
}

Here’s the effect:

onBlur callback — an imperfect method

While it appears the dropdown gets dismissed when clicking outside the menu, it doesn’t always work.

The onBlur callback is called only after the dropdown menu gets the focus. If the action of clicking outside the component occurs before the dropdown menu gets focused, the onBlur callback won't be called. That’s the case even if there are no elements to be focused inside the dropdown menu.

So, onBlur is not the answer.

Attempt 2: Try document.addEventListener()

When we implement a dropdown using original JavaScript without any framework, we use document.addEventListener('click', clickHandler)to listen to the click event no matter where it occurs. Let’s try the same with React as well:

componentDidMount() {
document.addEventListener('click', this.globalClickListener)
}
componentWillUnmount() {
document.removeEventListener('click', this.globalClickListener)
}
globalClickListener = (e) => {
console.log('global click')
this.setState({dropdownVisible: false})
}

Don’t forget to remove the click event handler in the componentWillUnmount()lifecycle to avoid the memory leak.

The effect:

The dropdown menu has disappeared

But wait, why did the dropdown menu disappear? Because clicking the button will trigger a global click event as well.

It appears that we should add and remove the global click listener dynamically — that is, add it only after the dropdown menu displays and remove it after the dropdown menu is dismissed.

Dynamically call document.addEventListener:

// componentDidMount() {
// document.addEventListener('click', this.globalClickListener)
// }
componentWillUnmount() {
document.removeEventListener('click', this.globalClickListener)
}
globalClickListener = (e) => {
console.log('global click')
this.setState({dropdownVisible: false}, () => {
document.removeEventListener('click', this.globalClickListener)
})
}
toggleDropdown = (e) => {
this.setState(prevState => ({dropdownVisible: !prevState.dropdownVisible}), () => {
if (this.state.dropdownVisible) {
document.addEventListener('click', this.globalClickListener)
}
})
}

The effect:

Almost, but not quite…

Once we dynamically call the document event listener, the dropdown gets dismissed as expected when clicking outside the component. You’ll also notice that the dropdown menu was dismissed when choosing the option inside the menu. If this is the desired result for you, that’s great, you’re done! For our purposes, however, we want to keep the dropdown open when clicking inside the menu, so let’s continue.

event.stopPropagation secret

Just as in the scenario above, since we’ve listened to the global click event, clicking inside the menu will trigger the global click handler as well. We need to prevent the event from propagating to the document and triggering the global click handler by using the event.stopPropagation API:

toggleDropdown = (event) => {
this.setState(prevState => ({dropdownVisible: !prevState.dropdownVisible}), () => {
if (this.state.dropdownVisible) {
document.addEventListener('click', this.globalClickListener)
}
})
}
handleBodyClick = (event) => {
console.log('body click')
event.stopPropagation()
}
renderDropdownMenu() {
return (
<div className='dropdown-body' onClick={this.handleBodyClick}>
<div>
<input type='checkbox'/><span>option 1</span>
</div>
<div>
<input type='checkbox'/><span>option 2</span>
</div>
</div>
)
}

The effect:

Oddly, it appears thatevent.stopPropagation didn’t work at all; the event still propagates to the document. When we clicked inside the dropdown menu, both the body click handler and the global click handler are called according to the log. Why did this happen?

This situation made me realize that there are two types of events in React: Native Events and Synthetic Events. A synthetic event is a Javascript wrapper abstracted across the actual event that allows you to make calls across different platforms.

  • We use thedocument.addEventListener API to handle the native event.
  • Synthetic events, which wrap the native events, bind all native events in the document object instead of the component itself. We call this the proxy mode.
  • When clicking inside the dropdown menu, handleBodyClick(event) is called, but since the native event has already occurred in the document, globalClickListener(event) is called as well. Notice the former event (handleBodyClick) is a React synthetic event, while the latter (globalClickListener) is a native event.

Just to verify, let’s log some information in the handleBodyClick handler:

handleBodyClick = (syntheticEvent) => {
console.log('body click')
console.log(syntheticEvent)
console.log(syntheticEvent.nativeEvent)
console.log(syntheticEvent.nativeEvent.path)
syntheticEvent.stopPropagation()
}

Here’s the output log after clicking the checkbox inside the dropdown menu:

From syntheticEvent.navtiveEvent.path, we know that the native event has already propagated to the document, so syntheticEvent.stopPropagation can only stop the native event from propagating to the window. That reminds me: why don’t we bind the global native click listener to the window object instead of the document object?

Before trying that, let’s summarize what the React Synthetic Event’s stopPropgation method can do:

  1. Stop the native event from propagating to the window object
  2. Stop the React synthetic event from propagating to the parent React components

Attempt 3: Try event.stopPropagation and window.addEventListener

Let’s listen to the native global click event in the window object:

componentWillUnmount() {
// document.removeEventListener('click', this.globalClickListener)
window.removeEventListener('click', this.globalClickListener)
}
globalClickListener = (nativeEvent) => {
console.log('global click')
this.setState({dropdownVisible: false}, () => {
// document.removeEventListener('click', this.globalClickListener)
window.removeEventListener('click', this.globalClickListener)
})
}
toggleDropdown = (syntheticEvent) => {
console.log('toggle dropdown')
this.setState(prevState => ({dropdownVisible: !prevState.dropdownVisible}), () => {
if (this.state.dropdownVisible) {
// document.addEventListener('click', this.globalClickListener)
window.addEventListener('click', this.globalClickListener)
}
})
}

The effect:

The result is worse than before; the dropdown won’t even open. But don’t worry, let’s try to figure out what happened.

When we click the toggle button, toggleDropdown is called, dropdown Visible is set to true, and the global native click listener is registered in the window. The current native click event propagates to the window object immediately, global native click listener is then called, and dropdownVisible is set to false. As such, the dropdown menu doesn't open.

There’s a simple solution to this problem: when we click the toggle button, we just need to stop the native click event from propagating to the window.

toggleDropdown = (syntheticEvent) => {
console.log('toggle dropdown')
syntheticEvent.stopPropagation()
this.setState(prevState => ({dropdownVisible: !prevState.dropdownVisible}), () => {
if (this.state.dropdownVisible) {
// document.addEventListener('click', this.globalClickListener)
window.addEventListener('click', this.globalClickListener)
}
})
}

The effect:

Finally, it works as anticipated.

However, this method doesn’t work when there is more than one dropdown menu on the same page.

The pitfall of event.stopPropagation and window.addEventListener

If there are 2 dropdown menus on the same page, when you click on the second dropdown menu trigger button after the first dropdown menu has already been selected, syntheticEvent.stopPropagation inside the trigger button click handler gets called. This prevents the global native click handler from getting called. As such, you can see that the first dropdown menu can't be dismissed when we add a new button that calls event.stopPropagation when it’s clicked:

render() {
return (
<div className='dropdown-container'>
<button onClick={(e)=>e.stopPropagation()}>stop propagation</button>
...
</div>
)
}

The effect:

Unfortunately, listening to a global native click event in the window object and stopping propagation is also not the ideal method. We’ll need to find a better solution. Stayed tuned for the last installment of this series to find out how to dismiss a dropdown when clicking outside its container.