Dismissing a React Dropdown Menu by Clicking Outside its Container

Byte-Sized Series 1, Part 3

Welcome back to the last of our dropdown menu series. Today, we’ll discuss the solution we’ve found to dismissing a dropdown by clicking outside its container.


Attempt 4: Try node.contains() and document.addEventListener()

In our fourth and last attempt, we’ll abandon the stop propagation method and come back to listening to the document object. There is a DOM API called node.contains(otherNode) that is used to check whether one node is inside in another node. We can use this API to differentiate when a click event happens inside the dropdown menu or outside of it.

Here we go: abandon stop propagation, go back to listening to the document object, and use the node.contains API to determine whether a click event happens outside the dropdown menu. If it does, hide the dropdown menu, since we need to use ref to save the DOM node in React in order to use the API.

componentWillUnmount() {
document.removeEventListener('click', this.globalClickListener)
}
globalClickListener = (nativeEvent) => {
console.log('global click')
// ignore click event happened inside the dropdown menu
if (this._dropdown_body && this._dropdown_body.contains(nativeEvent.target)) return
// else hide dropdown menu
this.setState({dropdownVisible: false}, () => {
document.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)
}
})
}
handleBodyClick = (syntheticEvent) => {
console.log('body click')
}
renderDropdownMenu() {
return (
<div className='dropdown-body'
ref={ref=>this._dropdown_body=ref}
onClick={this.handleBodyClick}>
<div>
<input type='checkbox'/><span>option 1</span>
</div>
<div>
<input type='checkbox'/><span>option 2</span>
</div>
</div>
)
}

The effect:

Wrap to NativeClickListener component

This works as anticipated, but let’s go even further. If you have multiple dropdown menus in your project, you don’t want to write the same code again and again, right? It makes sense to encapsulate the repeated code as an individual component. Let’s call this individual component the NativeClickListener.

import React from 'react'
import PropTypes from 'prop-types'
export default class NativeClickListener extends React.Component {
static propsType = {
onClick: PropTypes.func
}
  componentDidMount() {
document.addEventListener('click', this.globalClickHandler)
}
  componentWillUnmount() {
document.removeEventListener('click', this.globalClickHandler)
}
  globalClickHandler = (nativeEvent) => {
if (this._container && this._container.contains(nativeEvent.target)) return
this.props.onClick(nativeEvent)
}
  render() {
return (
<div ref={ref=>this._container=ref}>
{ this.props.children }
</div>
)
}
}

Usage:

toggleDropdown = (syntheticEvent) => {
console.log('toggle dropdown')
this.setState(prevState => ({dropdownVisible: !prevState.dropdownVisible}))
}
handleBodyClick = (syntheticEvent) => {
console.log('body click')
}
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>
)
}
render() {
return (
<div className='dropdown-container'>
<div className='dropdown-trigger'>
<button onClick={this.toggleDropdown}>
dropdown trigger
</button>
</div>
{
this.state.dropdownVisible &&
<NativeClickListener onClick={()=>this.setState({dropdownVisible: false})}>
{ this.renderDropdownMenu() }
</NativeClickListener>
}
</div>
)
}

We can simplify the code even further here.

In the toggleDropdown method, after opening the dropdown menu via setState({dropdownVisible: true}) when clicking the trigger button, the NativeClickListener will be responsible for closing the dropdown by setState({dropdownVisible: false}). This method will be called last, so whichever dropdownVisible was set before will be overridden by setState({dropdownVisible: false}).

Since the trigger button won’t be used to close the dropdown menu, just to open it, that means the toggleDropdown method can be removed:

<button onClick={()=>this.setState({dropdownVisible: true})}>
dropdown trigger
</button>

Sometimes, we’ll need to close the dropdown menu by clicking inside the menu, so let’s add one more prop, listenInside, for the NativeClickListener to support this operation.

static propsType = {
listenInside: PropTypes.bool,
onClick: PropTypes.func
}
globalClickHandler = (nativeEvent) => {
const { onClick, listenInside } = this.props
if (this._container &&
this._container.contains(nativeEvent.target) &&
!listenInside) return
onClick(nativeEvent)
}

Usage:

class DropdownPage extends Component {
constructor(props) {
super(props)
    this.state = {
dropdownVisible: false,
dropdown2Visible: false
}
}
  render() {
return (
<div>
<div className='dropdown-container' style={{float: 'left'}}>
<div className='dropdown-trigger'>
<button onClick={()=>this.setState({dropdownVisible: true})}>
dropdown trigger (listen outside)
</button>
</div>
{
this.state.dropdownVisible &&
<NativeClickListener
onClick={()=>this.setState({dropdownVisible: false})}>
<div className='dropdown-body'>
<div>
<input type='checkbox'/><span>option 1</span>
</div>
<div>
<input type='checkbox'/><span>option 2</span>
</div>
</div>
</NativeClickListener>
}
</div>
<div className='dropdown-container' style={{float: 'left'}}>
<div className='dropdown-trigger'>
<button onClick={()=>this.setState({dropdown2Visible: true})}>
dropdown trigger2 (listen inside)
</button>
</div>
{
this.state.dropdown2Visible &&
<NativeClickListener
listenInside={true}
onClick={()=>this.setState({dropdown2Visible: false})}>
<div className='dropdown-body'>
<div>
<span>menu 1</span>
</div>
<div>
<span>menu 2</span>
</div>
</div>
</NativeClickListener>
}
</div>
</div>
)
}
}

Final effect:

We can now even dismiss the dropdown menu by clicking only a part of the elements inside the menu:

{
this.state.dropdownVisible &&
<NativeClickListener
onClick={()=>this.setState({dropdownVisible: false})}>
<div className='dropdown-body'>
<div>
<input type='checkbox'/><span>option 1</span>
</div>
<div>
<input type='checkbox'/><span>option 2</span>
</div>
<button onClick={()=>this.setState({dropdownVisible: false})}>
OK
</button>
</div>
</NativeClickListener>
}

Effect:

Conclusion

We can dismiss a dropdown menu by encapsulating a React component called NativeClickListener using node.contains and the document.addEventListen API to listen to the global native click event.

Of course, there are already libraries for this operation:

But from our series, you’ll know exactly how they’re implemented!

You can find all the codes in this GitHub repo.

Happy coding!