95

I want to close a dropdown menu when a click occurs outside of the dropdown component.

How do I do that?

the Tin Man
  • 158,662
  • 42
  • 215
  • 303
Allan Hortle
  • 2,435
  • 2
  • 20
  • 22

12 Answers12

74

Using the life-cycle methods add and remove event listeners to the document.

React.createClass({
    handleClick: function (e) {
        if (this.getDOMNode().contains(e.target)) {
            return;
        }
    },

    componentWillMount: function () {
        document.addEventListener('click', this.handleClick, false);
    },

    componentWillUnmount: function () {
        document.removeEventListener('click', this.handleClick, false);
    }
});

Check out lines 48-54 of this component: https://github.com/i-like-robots/react-tube-tracker/blob/91dc0129a1f6077bef57ea4ad9a860be0c600e9d/app/component/tube-tracker.jsx#L48-54

Gajus
  • 69,002
  • 70
  • 275
  • 438
i_like_robots
  • 2,760
  • 2
  • 19
  • 23
  • This adds one to the document, but that means that any click events on the component also trigger the document event. This causes it's own issues because the document listener's target is always some low down div at the end of a component, not the component itself. – Allan Hortle May 25 '14 at 22:54
  • I'm not quite sure I follow your comment but if you bind event listeners to the document you can filter any click events that bubble up to it, either by matching a selector (standard event delegation) or by any other arbitary requirements (E.G. was the target element *not* within another element). – i_like_robots May 27 '14 at 11:34
  • 3
    This does cause issues as @AllanHortle points out. stopPropagation on a react event won't prevent the document event handlers from receiving the event. – Matt Wonlaw Jul 21 '14 at 01:36
  • edited, as this.getDOMNode() is Obsolete now ( https://facebook.github.io/react/docs/component-api.html ) – csomakk Aug 25 '15 at 10:24
  • 4
    To anyone interested I was having issues with stopPropagation when using the document. If you attach your event listener to window it seems to fix this issue? – Tito Aug 27 '15 at 13:03
  • Worked great! Thank you. I made a little bit of a tweak. For example, `if (!this.refs[YOUR_REF_HERE].contains(e.target)) { this.setState({ isDropdownMenuOpen: false }); }`. I was checking the false value instead. – Con Antonakos Apr 05 '16 at 21:51
  • Wow, you're right. I wonder why would using window work instead of document? – Kabir Sarin Apr 29 '16 at 06:41
  • 10
    As mentioned this.getDOMNode() is Obsolete. use ReactDOM instead like this: ReactDOM.findDOMNode(this).contains(e.target) – Arne H. Bitubekk Jul 07 '16 at 07:27
  • **Changed in latest React:** `ReactDOM.findDOMNode(this).contains(e.target) ` – Tan Dat Sep 06 '16 at 09:08
  • ReactDOM.findDOMNode will be deprecated as well. Use refs. https://github.com/yannickcr/eslint-plugin-react/issues/678#issue-165177220 – mawburn Feb 22 '17 at 15:53
60

In the element I have added mousedown and mouseup like this:

onMouseDown={this.props.onMouseDown} onMouseUp={this.props.onMouseUp}

Then in the parent I do this:

componentDidMount: function () {
    window.addEventListener('mousedown', this.pageClick, false);
},

pageClick: function (e) {
  if (this.mouseIsDownOnCalendar) {
      return;
  }

  this.setState({
      showCal: false
  });
},

mouseDownHandler: function () {
    this.mouseIsDownOnCalendar = true;
},

mouseUpHandler: function () {
    this.mouseIsDownOnCalendar = false;
}

The showCal is a boolean that when true shows in my case a calendar and false hides it.

Gajus
  • 69,002
  • 70
  • 275
  • 438
Robbert van Elk
  • 786
  • 7
  • 9
  • this ties the click specifically to a mouse, though. Click events can be generated by touch events and enter keys too, which this solution won't be able to react to, making it unsuitable for mobile and accessibility purposes =( – Mike 'Pomax' Kamermans Dec 30 '14 at 17:50
  • @Mike'Pomax'Kamermans You can now use onTouchStart and onTouchEnd for mobile. https://facebook.github.io/react/docs/events.html#touch-events – naoufal May 17 '15 at 16:40
  • 4
    Those have existed for a long time, but won't play nice with android, because you need to call `preventDefault()` on the events immediately or the native Android behaviour kicks in, which React's preprocessing interferes with. I wrote https://www.npmjs.com/package/react-onclickoutside since. – Mike 'Pomax' Kamermans May 17 '15 at 16:56
  • I love it! Commended. Removing event listener on mousedown will be helpful. componentWillUnmount = () => window.removeEventListener('mousedown', this.pageClick, false); – Juni Brosas Feb 14 '17 at 03:51
17

Look at the target of the event, if the event was directly on the component, or children of that component, then the click was inside. Otherwise it was outside.

React.createClass({
    clickDocument: function(e) {
        var component = React.findDOMNode(this.refs.component);
        if (e.target == component || $(component).has(e.target).length) {
            // Inside of the component.
        } else {
            // Outside of the component.
        }

    },
    componentDidMount: function() {
        $(document).bind('click', this.clickDocument);
    },
    componentWillUnmount: function() {
        $(document).unbind('click', this.clickDocument);
    },
    render: function() {
        return (
            <div ref='component'>
                ...
            </div> 
        )
    }
});

If this is to be used in many components, it is nicer with a mixin:

var ClickMixin = {
    _clickDocument: function (e) {
        var component = React.findDOMNode(this.refs.component);
        if (e.target == component || $(component).has(e.target).length) {
            this.clickInside(e);
        } else {
            this.clickOutside(e);
        }
    },
    componentDidMount: function () {
        $(document).bind('click', this._clickDocument);
    },
    componentWillUnmount: function () {
        $(document).unbind('click', this._clickDocument);
    },
}

See example here: https://jsfiddle.net/0Lshs7mg/1/

j-a
  • 1,780
  • 1
  • 21
  • 19
  • @Mike'Pomax'Kamermans that has been fixed, I think this answer adds useful information, perhaps your comment may now be removed. – j-a Aug 17 '15 at 07:05
  • you have reverted my changes for a wrong reason. `this.refs.component` is referring to the DOM element as of 0.14 http://facebook.github.io/react/blog/2015/07/03/react-v0.14-beta-1.html – Gajus Aug 17 '15 at 16:59
  • @GajusKuizinas - fine to make that change once 0.14 is the latest release (now is beta). – j-a Aug 18 '15 at 06:30
  • What is dollar? – pronebird Feb 17 '17 at 13:31
  • 2
    I hate to poke jQuery with React since they are two view's framework. – Abdennour TOUMI Feb 27 '17 at 18:14
  • aren't mixins being deprecated? – Thomas Browne Aug 20 '17 at 19:32
  • @ThomasBrowne not deprecated, though if you have issues, there are alternative approaches. https://facebook.github.io/react/blog/2016/07/13/mixins-considered-harmful.html – j-a Aug 21 '17 at 08:48
  • @AbdennourTOUMI I agree. One could pass "container" as ref, and replace the jquery .has(e.target) with component.contains(..) – j-a Aug 21 '17 at 09:05
12

For your specific use case, the currently accepted answer is a tad over-engineered. If you want to listen for when a user clicks out of a dropdown list, simply use a <select> component as the parent element and attach an onBlur handler to it.

The only drawbacks to this approach is that it assumes the user has already maintained focus on the element, and it relies on a form control (which may or may not be what you want if you take into account that the tab key also focuses and blurs elements) - but these drawbacks are only really a limit for more complicated use cases, in which case a more complicated solution might be necessary.

 var Dropdown = React.createClass({

   handleBlur: function(e) {
     // do something when user clicks outside of this element
   },

   render: function() {
     return (
       <select onBlur={this.handleBlur}>
         ...
       </select>
     );
   }
 });
razorbeard
  • 2,924
  • 1
  • 22
  • 29
5

I have written a generic event handler for events that originate outside of the component, react-outside-event.

The implementation itself is simple:

  • When component is mounted, an event handler is attached to the window object.
  • When an event occurs, the component checks whether the event originates from within the component. If it does not, then it triggers onOutsideEvent on the target component.
  • When component is unmounted, the event handler is detacthed.
import React from 'react';
import ReactDOM from 'react-dom';

/**
 * @param {ReactClass} Target The component that defines `onOutsideEvent` handler.
 * @param {String[]} supportedEvents A list of valid DOM event names. Default: ['mousedown'].
 * @return {ReactClass}
 */
export default (Target, supportedEvents = ['mousedown']) => {
    return class ReactOutsideEvent extends React.Component {
        componentDidMount = () => {
            if (!this.refs.target.onOutsideEvent) {
                throw new Error('Component does not defined "onOutsideEvent" method.');
            }

            supportedEvents.forEach((eventName) => {
                window.addEventListener(eventName, this.handleEvent, false);
            });
        };

        componentWillUnmount = () => {
            supportedEvents.forEach((eventName) => {
                window.removeEventListener(eventName, this.handleEvent, false);
            });
        };

        handleEvent = (event) => {
            let target,
                targetElement,
                isInside,
                isOutside;

            target = this.refs.target;
            targetElement = ReactDOM.findDOMNode(target);
            isInside = targetElement.contains(event.target) || targetElement === event.target;
            isOutside = !isInside;



            if (isOutside) {
                target.onOutsideEvent(event);
            }
        };

        render() {
            return <Target ref='target' {... this.props} />;
        }
    }
};

To use the component, you need wrap the target component class declaration using the higher order component and define the events that you want to handle:

import React from 'react';
import ReactDOM from 'react-dom';
import ReactOutsideEvent from 'react-outside-event';

class Player extends React.Component {
    onOutsideEvent = (event) => {
        if (event.type === 'mousedown') {

        } else if (event.type === 'mouseup') {

        }
    }

    render () {
        return <div>Hello, World!</div>;
    }
}

export default ReactOutsideEvent(Player, ['mousedown', 'mouseup']);
Gajus
  • 69,002
  • 70
  • 275
  • 438
4

I voted up one of the answers even though it didn't work for me. It ended up leading me to this solution. I changed the order of operations slightly. I listen for mouseDown on the target and mouseUp on the target. If either of those return TRUE, we don't close the modal. As soon as a click is registered, anywhere, those two booleans { mouseDownOnModal, mouseUpOnModal } are set back to false.

componentDidMount() {
    document.addEventListener('click', this._handlePageClick);
},

componentWillUnmount() {
    document.removeEventListener('click', this._handlePageClick);
},

_handlePageClick(e) {
    var wasDown = this.mouseDownOnModal;
    var wasUp = this.mouseUpOnModal;
    this.mouseDownOnModal = false;
    this.mouseUpOnModal = false;
    if (!wasDown && !wasUp)
        this.close();
},

_handleMouseDown() {
    this.mouseDownOnModal = true;
},

_handleMouseUp() {
    this.mouseUpOnModal = true;
},

render() {
    return (
        <Modal onMouseDown={this._handleMouseDown} >
               onMouseUp={this._handleMouseUp}
            {/* other_content_here */}
        </Modal>
    );
}

This has the advantage that all the code rests with the child component, and not the parent. It means that there's no boilerplate code to copy when reusing this component.

Steve Zelaznik
  • 616
  • 7
  • 16
3
  1. Create a fixed layer that spans the whole screen (.backdrop).
  2. Have the target element (.target) outside the .backdrop element and with a greater stacking index (z-index).

Then any click on the .backdrop element will be considered "outside of the .target element".

.click-overlay {
    position: fixed;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    z-index: 1;
}

.target {
    position: relative;
    z-index: 2;
}
Gajus
  • 69,002
  • 70
  • 275
  • 438
Federico
  • 6,388
  • 6
  • 35
  • 43
2

You could use refs to achieve this, something like the following should work.

Add the ref to your element:

<div ref={(element) => { this.myElement = element; }}></div>

You can then add a function for handling the click outside of the element like so:

handleClickOutside(e) {
  if (!this.myElement.contains(e)) {
    this.setState({ myElementVisibility: false });
  }
}

Then finally, add and remove the event listeners on will mount and will unmount.

componentWillMount() {
  document.addEventListener('click', this.handleClickOutside, false);  // assuming that you already did .bind(this) in constructor
}

componentWillUnmount() {
  document.removeEventListener('click', this.handleClickOutside, false);  // assuming that you already did .bind(this) in constructor
}
Nah
  • 1,690
  • 2
  • 26
  • 46
  • Modified your answer, it had possible error in reference to calling `handleClickOutside` in `document.addEventListener()` by adding `this` reference. Otherwise it gives *Uncaught ReferenceError: handleClickOutside is not defined* in `componentWillMount()` – Nah Oct 24 '17 at 09:55
1

Super late to the party, but I've had success with setting a blur event on the parent element of the dropdown with the associated code to close the dropdown, and also attaching a mousedown listener to the parent element that checks if the dropdown is open or not, and will stop the event propagation if it is open so that the blur event won't be triggered.

Since the mousedown event bubbles up this will prevent any mousedown on children from causing a blur on the parent.

/* Some react component */
...

showFoo = () => this.setState({ showFoo: true });

hideFoo = () => this.setState({ showFoo: false });

clicked = e => {
    if (!this.state.showFoo) {
        this.showFoo();
        return;
    }
    e.preventDefault()
    e.stopPropagation()
}

render() {
    return (
        <div 
            onFocus={this.showFoo}
            onBlur={this.hideFoo}
            onMouseDown={this.clicked}
        >
            {this.state.showFoo ? <FooComponent /> : null}
        </div>
    )
}

...

e.preventDefault() shouldn't have to be called as far as I can reason but firefox doesn't play nice without it for whatever reason. Works on Chrome, Firefox, and Safari.

0

I found a simpler way about this.

You just need to add onHide(this.closeFunction) on the modal

<Modal onHide={this.closeFunction}>
...
</Modal>

Assuming you have a function to close the modal.

M.R. Murazza
  • 346
  • 3
  • 12
0

If you want this solution in a react functional component it's: (this also works if you want a button inside the element which also closes it)

function myComponent({ useOpenState }) {
  // assuming parent manages state which determines whether the element is visible, if not it can be set in this component
  const [open, setOpen] = useOpenState
  const elementRef = React.useRef()

  const close = () => {
    setOpen(false)
  }

  const handleOffClick = (event) => {
    if (elementRef.current.contains(event.target)) {
      return
    }
    close()
  }

  useEffect(() => {
    document.addEventListener('click', handleOffClick, false)

    return () => {
      document.removeEventListener('click', handleOffClick, false)
    }
  }, [])

  return (
    <div>
      <div>
        Element you want to close by clicking away from it
        <button onClick={close}>close</button>
        But can also close inside it with this button
      </div> 
    </div>
  )

}
-1

Use the excellent react-onclickoutside mixin:

npm install --save react-onclickoutside

And then

var Component = React.createClass({
  mixins: [
    require('react-onclickoutside')
  ],
  handleClickOutside: function(evt) {
    // ...handling code goes here... 
  }
});
e18r
  • 7,578
  • 4
  • 45
  • 40