Thijs Koerselman
Thijs Koerselman

Reputation: 23300

Detect click outside React component

I'm looking for a way to detect if a click event happened outside of a component, as described in this article. jQuery closest() is used to see if the target from a click event has the dom element as one of its parents. If there is a match the click event belongs to one of the children and is thus not considered to be outside of the component.

So in my component, I want to attach a click handler to the window. When the handler fires I need to compare the target with the dom children of my component.

The click event contains properties like "path" which seems to hold the dom path that the event has traveled. I'm not sure what to compare or how to best traverse it, and I'm thinking someone must have already put that in a clever utility function... No?

Upvotes: 933

Views: 995946

Answers (30)

Géry Ogam
Géry Ogam

Reputation: 8077

Here is a TypeScript version inspired from Tanner Linsley’s talk at JSConf Hawaii 2020:

import { MutableRefObject, useEffect, useRef } from 'react';

export function useClickOutside(
  elementRefs: MutableRefObject<HTMLElement | null>[],
  callback: (event: MouseEvent) => void
): void {
  const callbackRef = useRef(callback);
  callbackRef.current = callback;
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent): void => {
      const target = event.target;
      if (
        !(target instanceof Node) ||
        elementRefs.every((elementRef) => !elementRef.current?.contains(target))
      ) {
        callbackRef.current?.(event);
      }
    };
    document.addEventListener('click', handleClickOutside, true);
    return () =>
      document.removeEventListener('click', handleClickOutside, true);
  }, [elementRefs]);
}

Upvotes: 3

Taras Shevchenko
Taras Shevchenko

Reputation: 311

import { RefObject, useEffect } from 'react';

const useClickOutside = <T extends HTMLElement>(ref: RefObject<T>, fn: () => void) => {
    useEffect(() => {
        const element = ref?.current;
        function handleClickOutside(event: Event) {
            if (element && !element.contains(event.target as Node | null)) {
                fn();
            }
        }
        document.addEventListener('mousedown', handleClickOutside);
        return () => {
            document.removeEventListener('mousedown', handleClickOutside);
        };
    }, [ref, fn]);
};

export default useClickOutside;

Upvotes: 9

Rabah Ali Shah
Rabah Ali Shah

Reputation: 71

There is an npm module which will make your life easier to handle the clicks outside a specific component. For Example: You make states as true and false. On dropdown a menu list your state is true and on clicking on close button your state converts to false and dropdown menu component gets disappear. But you want to close this drop down menu also clicking on outside of the drop down menu on the window. To deal with such scenario follow the below steps:

 npm i react-outside-click-handler

Now Import this module in your React File:

import OutsideClickHandler from 'react-outside-click-handler';

Now You have imported a component from this module. This component takes a component outside of which you want to detect a click event

function MyComponent() {
  return (
    <OutsideClickHandler
      onOutsideClick={() => {
        alert("You clicked outside of this component!!!");
        //Or any logic you want
      }} >
      <yourComponent />
    </OutsideClickHandler>
  );
}

Now Simply replace you Own component with . I hope this find you helpful :)

Upvotes: 4

Kuza Grave
Kuza Grave

Reputation: 1574

The Ez way... (UPDATED 2023)

  • Create a hook: useOutsideClick.ts
export function useOutsideClick(ref: any, onClickOut: () => void, deps = []){
    useEffect(() => {
        const onClick = ({target}: any) => !ref?.contains(target) && onClickOut?.()
        document.addEventListener("click", onClick);
        return () => document.removeEventListener("click", onClick);
    }, deps);
}
  • Add componentRef to your component and call useOutsideClick
export function Example(){

  const ref: any = useRef();

  useOutsideClick(ref.current, () => {
    // do something here
  });

  return ( 
    <div ref={ref}> My Component </div>
  )
}

Upvotes: 50

Imron Rosadi
Imron Rosadi

Reputation: 91

In Typescript Case

  1. Create Hook, we assume useOutsideAlerter

    const useOutsideAlerter = (ref: RefObject<HTMLElement>, callback: () => void) => {
        useEffect(() => {
            const handleClickOutside = (event: MouseEvent) => {
                if (ref.current && !ref.current.contains(event.target as Node)) {
                    callback()
                }
            }
    
            document.addEventListener('mousedown', handleClickOutside)
            return () => {
                // Unbind the event listener on clean up
                document.removeEventListener('mousedown', handleClickOutside)
            }
        }, [ref])
    }
    
  2. Call Hook In Your Component

    const MyComponent: FC<{}> = (props) => {
        const wrapperRef = useRef(null)
    
        const handleOutSideClick = () => {
            alert('click outside component')
        }
    
        const handleInSideClick = () => {
            alert('click inside component')
        }
    
        useOutsideAlerter(wrapperRef, handleOutSideClick)
    
        return (
            <div ref={wrapperRef}><button onClick={handleInSideClick}>KLIK ME</button></div>
        )
    }
    

Upvotes: 2

Remo H. Jansen
Remo H. Jansen

Reputation: 25019

The most simple solution I have found is to use:

onBlur={(e) => {
  const parent = e.currentTarget.parentNode;
  const isDescendant = parent
    ? parent.contains(e.relatedTarget)
    : false;
  if (!isDescendant) {
    setIsOpen(false);
  }
}}

You might have to use multiple .parentNode until you find the dropdown root element (parent). If e.relatedTarget is not a descendant of parent then you can close the click was outside.

Upvotes: 1

Labhansh Satpute
Labhansh Satpute

Reputation: 459

Try this it works perfectly

import React, { useState, useRef, useEffect } from "react";

const Dropdown = () => {

  const [state, setState] = useState(true);
  const dropdownRef = useRef(null);

  const handleToggleDropdown = () => {
    if (state) setState(false);
    else setState(true);
  }

  const handleClickOutside = (event) => {
    if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
      setState(true);
    }
  }

  useEffect(() => {
    document.addEventListener('mousedown', handleClickOutside);
    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
    }
  }, []);

  return (
    <React.Fragment>
      <div className="dropdown" ref={dropdownRef}>
        <button onClick={() => handleToggleDropdown()}>
          Options
        </button>
        <div className="dropdown-content" hidden={state}>


        </div>
      </div>
    </React.Fragment>
  );
}


export default Dropdown;

Upvotes: 3

Allan Tanaka
Allan Tanaka

Reputation: 307

In addition to other solution, here is the improved function that can unsubscribe the listener and add an element or group of element of the React component to track the click. This hook can be used to handle scenarios such as closing a dropdown menu when a user clicks outside of it, or hiding a modal when the user clicks outside of it.

function useOutsideClick(
      reference: Array<React.RefObject<any>>,
      onClickOut: () => void,
      shouldListen = true,
    ) {
      useEffect(() => {
        const onClick = (event: Event) => {
          if (
            shouldListen &&
            reference.some(
              (reference_) =>
                reference_.current?.contains(event.target as Node) === false,
            )
          ) {
            return onClickOut();
          }
        };
        if (!shouldListen) {
          return document.removeEventListener('click', onClick);
        }
        document.addEventListener('click', onClick);
        return () => document.removeEventListener('click', onClick);
      }, [reference, shouldListen]);
    }

Upvotes: 0

Sundar Gautam
Sundar Gautam

Reputation: 473

If you need typescript version:

import React, { useRef, useEffect } from "react";

interface Props {
  ref: React.MutableRefObject<any>;

}

export const useOutsideAlerter = ({ ref }: Props) => {
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (ref.current && !ref.current.contains(event.target as Node)) {
       //do what ever you want
      }
    };
    // Bind the event listener
    document.addEventListener("mousedown", handleClickOutside);
    return () => {
      // Unbind the event listener on clean up
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, [ref]);
};
export default useOutsideAlerter;

If you want to extend this to close a modal or hide something you can also do:

import React, { useRef, useEffect } from "react";

interface Props {
  ref: React.MutableRefObject<any>;
  setter: React.Dispatch<React.SetStateAction<boolean>>;
}

export const useOutsideAlerter = ({ ref, setter }: Props) => {
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (ref.current && !ref.current.contains(event.target as Node)) {
        setter(false);
      }
    };
    // Bind the event listener
    document.addEventListener("mousedown", handleClickOutside);
    return () => {
      // Unbind the event listener on clean up
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, [ref, setter]);
};
export default useOutsideAlerter;

Upvotes: 4

alessandro308
alessandro308

Reputation: 2182

All the solution proposed assume that an event can be added to the document and rely on the native method .contains() to distinguish if the event is triggered inside or outside of the current component

ref.current.contains(event.target)

but this is not always valid in React. In React, in fact, there is the React.createPortal API that permit to specify from a component a new real parent component in which the JSX is rendered into but, at the same time, the event bubbling is simulated as is the component is rendered in the declared place (i.e. where React.createPortal is invoked).

This is achieved attaching all the event to the app root element and simulating the events in Javascript.

So the solution proposed is broken in that scenario because a click inside a portal element, that for the standard HTML is outside the current element, actually should be handled as it is inside.

So I rewritten a solution proposed in a comment in this question, and refactored it to use the functional component. This works also in case of one or multiple nested portals.

export default function OutsideClickDetector({onOutsideClick, Component ='div', ...props} : OutsideClickDetectorProps) {
    const isClickInside = useRef<boolean>(false);

    const onMouseDown = () => {
        isClickInside.current = true;
    };
    
    const handleBodyClick = useCallback((e) => {
        if(!isClickInside.current) {
            onOutsideClick?.(e);
        }
        isClickInside.current = false;
    }, [isClickInside, onOutsideClick]);

    useEffect(() => {
        document.addEventListener('mousedown', handleBodyClick);
        return () => document.removeEventListener('mousedown', handleBodyClick);
    });

    return <Component onMouseDown={onMouseDown} onMouseUp={() => isClickInside.current = false}{...props} />;
}

Upvotes: 0

Luciano Corniglione
Luciano Corniglione

Reputation: 511

Since for me the !ref.current.contains(e.target) wasn't working because the DOM elements contained inside the ref were changing, I came up with a slightly different solution:

function useClickOutside<T extends HTMLElement>(
  element: T | null,
  onClickOutside: () => void,
) {
  useEffect(() => {
    function handleClickOutside(event: MouseEvent) {
      const xCoord = event.clientX;
      const yCoord = event.clientY;

      if (element) {
        const { right, x, bottom, y } = element.getBoundingClientRect();
        if (xCoord < right && xCoord > x && yCoord < bottom && yCoord > y) {
          return;
        }

        onClickOutside();
      }
    }

    document.addEventListener('click', handleClickOutside);
    return () => {
      document.removeEventListener('click', handleClickOutside);
    };
  }, [element, onClickOutside]);

Upvotes: 3

Yilmaz
Yilmaz

Reputation: 49661

import React, { useState, useEffect, useRef } from "react";

const YourComponent: React.FC<ComponentProps> = (props) => {
  const ref = useRef<HTMLDivElement | null>(null);
  const [myState, setMyState] = useState(false);
  useEffect(() => {
    const listener = (event: MouseEvent) => {
      // we have to add some logic to decide whether or not a click event is inside of this editor
      // if user clicks on inside the div we dont want to setState
      // we add ref to div to figure out whether or not a user is clicking inside this div to determine whether or not event.target is inside the div
      if (
        ref.current &&
        event.target &&
        // contains is expect other: Node | null
        ref.current.contains(event.target as Node)
      ) {
        return;
      }
      // if we are outside
      setMyState(false);
    };
    // anytime user clics anywhere on the dom, that click event will bubble up into our body element
    // without { capture: true } it might not work
    document.addEventListener("click", listener, { capture: true });
    return () => {
      document.removeEventListener("click", listener, { capture: true });
    };
  }, []);

  return (
    <div  ref={ref}>
      ....
    </div>
  );
};

Upvotes: 0

Olivier Tassinari
Olivier Tassinari

Reputation: 8681

MUI has a small component to solve this problem: https://mui.com/base/react-click-away-listener/ that you can cherry-pick it. It weights below 1 kB gzipped, it supports mobile, IE 11, and portals.

Upvotes: 38

Erhan Namal
Erhan Namal

Reputation: 179

with typescript

function Tooltip(): ReactElement {
  const [show, setShow] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    function handleClickOutside(event: MouseEvent): void {
      if (ref.current && !ref.current.contains(event.target as Node)) {
        setShow(false);
      }
    }
    // Bind the event listener
    document.addEventListener('mousedown', handleClickOutside);
    return () => {
      // Unbind the event listener on clean up
      document.removeEventListener('mousedown', handleClickOutside);
    };
  });

  return (
    <div ref={ref}></div>
  ) 
 }

Upvotes: 15

Mohamad
Mohamad

Reputation: 79

Simply with ClickAwayListener from mui (material-ui):

<ClickAwayListener onClickAway={handleClickAway}>
    {children}
<ClickAwayListener >

for more info you can check:https://mui.com/base/react-click-away-listener/

Upvotes: 7

Koushith B.R
Koushith B.R

Reputation: 49

I had a similar use case where I had to develop a custom dropdown menu. it should close automatically when the user clicks outside. here is the recent React Hooks implementation-

import { useEffect, useRef, useState } from "react";

export const  App = () => {
  
  const ref = useRef();

  const [isMenuOpen, setIsMenuOpen] = useState(false);

  useEffect(() => {
    const checkIfClickedOutside = (e) => {
      // If the menu is open and the clicked target is not within the menu,
      // then close the menu
      if (isMenuOpen && ref.current && !ref.current.contains(e.target)) {
        setIsMenuOpen(false);
      }
    };

    document.addEventListener("mousedown", checkIfClickedOutside);

    return () => {
      // Cleanup the event listener
      document.removeEventListener("mousedown", checkIfClickedOutside);
    };
  }, [isMenuOpen]);

  return (
    <div className="wrapper" ref={ref}>
      <button
        className="button"
        onClick={() => setIsMenuOpen((oldState) => !oldState)}
      >
        Click Me
      </button>
      {isMenuOpen && (
        <ul className="list">
          <li className="list-item">dropdown option 1</li>
          <li className="list-item">dropdown option 2</li>
          <li className="list-item">dropdown option 3</li>
          <li className="list-item">dropdown option 4</li>
        </ul>
      )}
    </div>
  );
}

Upvotes: 3

Sebastian Nielsen
Sebastian Nielsen

Reputation: 4219

Typescript + simplified version of @ford04's proposal:

useOuterClick API

const Client = () => {
  const ref = useOuterClick<HTMLDivElement>(e => { /* Custom-event-handler */ });
  return <div ref={ref}> Inside </div> 
};

Implementation

export default function useOuterClick<T extends HTMLElement>(callback: Function) {
  const callbackRef = useRef<Function>(); // initialize mutable ref, which stores callback
  const innerRef = useRef<T>(null); // returned to client, who marks "border" element

  // update cb on each render, so second useEffect has access to current value
  useEffect(() => { callbackRef.current = callback; });

  useEffect(() => {
    document.addEventListener("click", _onClick);
    return () => document.removeEventListener("click", _onClick);
    function _onClick(e: any): void {
      const clickedOutside = !(innerRef.current?.contains(e.target));
      if (clickedOutside)
        callbackRef.current?.(e);
    }
  }, []); // no dependencies -> stable click listener

  return innerRef; // convenience for client (doesn't need to init ref himself)
}

Upvotes: 7

Abdelatif D.
Abdelatif D.

Reputation: 61

This is my way of solving the problem

I return a boolean value from my custom hook, and when this value changes (true if the click was outside of the ref that I passed as an arg), this way i can catch this change with an useEffect hook, i hope it's clear for you.

Here's a live example: Live Example on codesandbox

import { useEffect, useRef, useState } from "react";

const useOutsideClick = (ref) => {
  const [outsieClick, setOutsideClick] = useState(null);

  useEffect(() => {
    const handleClickOutside = (e) => {
      if (!ref.current.contains(e.target)) {
        setOutsideClick(true);
      } else {
        setOutsideClick(false);
      }

      setOutsideClick(null);
    };

    document.addEventListener("mousedown", handleClickOutside);

    return () => {
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, [ref]);

  return outsieClick;
};

export const App = () => {
  const buttonRef = useRef(null);
  const buttonClickedOutside = useOutsideClick(buttonRef);

  useEffect(() => {
    // if the the click was outside of the button
    // do whatever you want
    if (buttonClickedOutside) {
      alert("hey you clicked outside of the button");
    }
  }, [buttonClickedOutside]);

  return (
    <div className="App">
      <button ref={buttonRef}>click outside me</button>
    </div>
  );
}

Upvotes: 4

Ben Bud
Ben Bud

Reputation: 16990

The following solution uses ES6 and follows best practices for binding as well as setting the ref through a method.

To see it in action:

Hooks Implementation:

import React, { useRef, useEffect } from "react";

/**
 * Hook that alerts clicks outside of the passed ref
 */
function useOutsideAlerter(ref) {
  useEffect(() => {
    /**
     * Alert if clicked on outside of element
     */
    function handleClickOutside(event) {
      if (ref.current && !ref.current.contains(event.target)) {
        alert("You clicked outside of me!");
      }
    }
    // Bind the event listener
    document.addEventListener("mousedown", handleClickOutside);
    return () => {
      // Unbind the event listener on clean up
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, [ref]);
}

/**
 * Component that alerts if you click outside of it
 */
export default function OutsideAlerter(props) {
  const wrapperRef = useRef(null);
  useOutsideAlerter(wrapperRef);

  return <div ref={wrapperRef}>{props.children}</div>;
}

Class Implementation:

After 16.3

import React, { Component } from "react";

/**
 * Component that alerts if you click outside of it
 */
export default class OutsideAlerter extends Component {
  constructor(props) {
    super(props);

    this.wrapperRef = React.createRef();
    this.handleClickOutside = this.handleClickOutside.bind(this);
  }

  componentDidMount() {
    document.addEventListener("mousedown", this.handleClickOutside);
  }

  componentWillUnmount() {
    document.removeEventListener("mousedown", this.handleClickOutside);
  }

  /**
   * Alert if clicked on outside of element
   */
  handleClickOutside(event) {
    if (this.wrapperRef && !this.wrapperRef.current.contains(event.target)) {
      alert("You clicked outside of me!");
    }
  }

  render() {
    return <div ref={this.wrapperRef}>{this.props.children}</div>;
  }
}

Before 16.3

import React, { Component } from "react";

/**
 * Component that alerts if you click outside of it
 */
export default class OutsideAlerter extends Component {
  constructor(props) {
    super(props);

    this.setWrapperRef = this.setWrapperRef.bind(this);
    this.handleClickOutside = this.handleClickOutside.bind(this);
  }

  componentDidMount() {
    document.addEventListener("mousedown", this.handleClickOutside);
  }

  componentWillUnmount() {
    document.removeEventListener("mousedown", this.handleClickOutside);
  }

  /**
   * Set the wrapper ref
   */
  setWrapperRef(node) {
    this.wrapperRef = node;
  }

  /**
   * Alert if clicked on outside of element
   */
  handleClickOutside(event) {
    if (this.wrapperRef && !this.wrapperRef.contains(event.target)) {
      alert("You clicked outside of me!");
    }
  }

  render() {
    return <div ref={this.setWrapperRef}>{this.props.children}</div>;
  }
}

Upvotes: 1585

Zak
Zak

Reputation: 940

So I faced a similar problem but in my case the selected answer here wasn't working because I had a button for the dropdown which is, well, a part of the document. So clicking the button also triggered the handleClickOutside function. To stop that from triggering, I had to add a new ref to the button and this !menuBtnRef.current.contains(e.target) to the conditional. I'm leaving it here if someone is facing the same issue like me.

Here's how the component looks like now:


const Component = () => {

    const [isDropdownOpen, setIsDropdownOpen] = useState(false);
    const menuRef     = useRef(null);
    const menuBtnRef  = useRef(null);

    const handleDropdown = (e) => {
        setIsDropdownOpen(!isDropdownOpen);
    }

    const handleClickOutside = (e) => {
        if (menuRef.current && !menuRef.current.contains(e.target) && !menuBtnRef.current.contains(e.target)) {
            setIsDropdownOpen(false);
        }
    }

    useEffect(() => {
        document.addEventListener('mousedown', handleClickOutside, true);
        return () => {
            document.removeEventListener('mousedown', handleClickOutside, true);
        };
    }, []);

    return (

           <button ref={menuBtnRef} onClick={handleDropdown}></button>

           <div ref={menuRef} className={`${isDropdownOpen ? styles.dropdownMenuOpen : ''}`}>
                // ...dropdown items
           </div>
    )
}

Upvotes: 4

Paul Fitzgerald
Paul Fitzgerald

Reputation: 12129

I was stuck on the same issue. I am a bit late to the party here, but for me this is a really good solution. Hopefully it will be of help to someone else. You need to import findDOMNode from react-dom

import ReactDOM from 'react-dom';
// ... ✂

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

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

handleClickOutside = event => {
    const domNode = ReactDOM.findDOMNode(this);

    if (!domNode || !domNode.contains(event.target)) {
        this.setState({
            visible: false
        });
    }
}

React Hooks Approach (16.8 +)

You can create a reusable hook called useComponentVisible.

import { useState, useEffect, useRef } from 'react';

export default function useComponentVisible(initialIsVisible) {
    const [isComponentVisible, setIsComponentVisible] = useState(initialIsVisible);
    const ref = useRef(null);

    const handleClickOutside = (event) => {
        if (ref.current && !ref.current.contains(event.target)) {
            setIsComponentVisible(false);
        }
    };

    useEffect(() => {
        document.addEventListener('click', handleClickOutside, true);
        return () => {
            document.removeEventListener('click', handleClickOutside, true);
        };
    }, []);

    return { ref, isComponentVisible, setIsComponentVisible };
}

Then in the component you wish to add the functionality to do the following:

const DropDown = () => {
    const { ref, isComponentVisible } = useComponentVisible(true);
    return (
       <div ref={ref}>
          {isComponentVisible && (<p>Dropdown Component</p>)}
       </div>
    );
 
}

Find a codesandbox example here.

Upvotes: 296

Pablo Barr&#237;a Urenda
Pablo Barr&#237;a Urenda

Reputation: 5813

2021 Update:

It has bee a while since I added this response, and since it still seems to garner some interest, I thought I would update it to a more current React version. On 2021, this is how I would write this component:

import React, { useState } from "react";
import "./DropDown.css";

export function DropDown({ options, callback }) {
    const [selected, setSelected] = useState("");
    const [expanded, setExpanded] = useState(false);

    function expand() {
        setExpanded(true);
    }

    function close() {
        setExpanded(false);
    }

    function select(event) {
        const value = event.target.textContent;
        callback(value);
        close();
        setSelected(value);
    }

    return (
        <div className="dropdown" tabIndex={0} onFocus={expand} onBlur={close} >
            <div>{selected}</div>
            {expanded ? (
                <div className={"dropdown-options-list"}>
                    {options.map((O) => (
                        <div className={"dropdown-option"} onClick={select}>
                            {O}
                        </div>
                    ))}
                </div>
            ) : null}
        </div>
    );
}

Original Answer (2016):

Here is the solution that best worked for me without attaching events to the container:

Certain HTML elements can have what is known as "focus", for example input elements. Those elements will also respond to the blur event, when they lose that focus.

To give any element the capacity to have focus, just make sure its tabindex attribute is set to anything other than -1. In regular HTML that would be by setting the tabindex attribute, but in React you have to use tabIndex (note the capital I).

You can also do it via JavaScript with element.setAttribute('tabindex',0)

This is what I was using it for, to make a custom DropDown menu.

var DropDownMenu = React.createClass({
    getInitialState: function(){
        return {
            expanded: false
        }
    },
    expand: function(){
        this.setState({expanded: true});
    },
    collapse: function(){
        this.setState({expanded: false});
    },
    render: function(){
        if(this.state.expanded){
            var dropdown = ...; //the dropdown content
        } else {
            var dropdown = undefined;
        }
        
        return (
            <div className="dropDownMenu" tabIndex="0" onBlur={ this.collapse } >
                <div className="currentValue" onClick={this.expand}>
                    {this.props.displayValue}
                </div>
                {dropdown}
            </div>
        );
    }
});

Upvotes: 255

ford04
ford04

Reputation: 74710

Hook implementation based on Tanner Linsley's excellent talk at JSConf Hawaii 2020:

useOuterClick API

const Client = () => {
  const innerRef = useOuterClick(ev => {/*event handler code on outer click*/});
  return <div ref={innerRef}> Inside </div> 
};

Implementation

function useOuterClick(callback) {
  const callbackRef = useRef(); // initialize mutable ref, which stores callback
  const innerRef = useRef(); // returned to client, who marks "border" element

  // update cb on each render, so second useEffect has access to current value 
  useEffect(() => { callbackRef.current = callback; });
  
  useEffect(() => {
    document.addEventListener("click", handleClick);
    return () => document.removeEventListener("click", handleClick);
    function handleClick(e) {
      if (innerRef.current && callbackRef.current && 
        !innerRef.current.contains(e.target)
      ) callbackRef.current(e);
    }
  }, []); // no dependencies -> stable click listener
      
  return innerRef; // convenience for client (doesn't need to init ref himself) 
}

Here is a working example:

/*
  Custom Hook
*/
function useOuterClick(callback) {
  const innerRef = useRef();
  const callbackRef = useRef();

  // set current callback in ref, before second useEffect uses it
  useEffect(() => { // useEffect wrapper to be safe for concurrent mode
    callbackRef.current = callback;
  });

  useEffect(() => {
    document.addEventListener("click", handleClick);
    return () => document.removeEventListener("click", handleClick);

    // read most recent callback and innerRef dom node from refs
    function handleClick(e) {
      if (
        innerRef.current && 
        callbackRef.current &&
        !innerRef.current.contains(e.target)
      ) {
        callbackRef.current(e);
      }
    }
  }, []); // no need for callback + innerRef dep
  
  return innerRef; // return ref; client can omit `useRef`
}

/*
  Usage 
*/
const Client = () => {
  const [counter, setCounter] = useState(0);
  const innerRef = useOuterClick(e => {
    // counter state is up-to-date, when handler is called
    alert(`Clicked outside! Increment counter to ${counter + 1}`);
    setCounter(c => c + 1);
  });
  return (
    <div>
      <p>Click outside!</p>
      <div id="container" ref={innerRef}>
        Inside, counter: {counter}
      </div>
    </div>
  );
};

ReactDOM.render(<Client />, document.getElementById("root"));
#container { border: 1px solid red; padding: 20px; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.12.0/umd/react.production.min.js" integrity="sha256-Ef0vObdWpkMAnxp39TYSLVS/vVUokDE8CDFnx7tjY6U=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.12.0/umd/react-dom.production.min.js" integrity="sha256-p2yuFdE8hNZsQ31Qk+s8N+Me2fL5cc6NKXOC0U9uGww=" crossorigin="anonymous"></script>
<script> var {useRef, useEffect, useCallback, useState} = React</script>
<div id="root"></div>

Key points

  • useOuterClick makes use of mutable refs to provide lean Client API
  • stable click listener for lifetime of containing component ([] deps)
  • Client can set callback without needing to memoize it by useCallback
  • callback body has access to the most recent props and state - no stale closure values

(Side note for iOS)

iOS in general treats only certain elements as clickable. To make outer clicks work, choose a different click listener than document - nothing upwards including body. E.g. add a listener on the React root div and expand its height, like height: 100vh, to catch all outside clicks. Source: quirksmode.org

Upvotes: 120

Chetan Jain
Chetan Jain

Reputation: 241

NON INTRUSTIVE WAY NO NEED TO ADD ANOTHER DIV EL.

Note: React may say findDomNode isDeprecated but till now I have not faced any issue with it

@exceptions: class to ignore if clicked on it

@idException: id to ignore if clicked on it

import React from "react"
import ReactDOM from "react-dom"
type Func1<T1, R> = (a1: T1) => R


export function closest(
    el: Element,
    fn: (el: Element) => boolean
  ): Element | undefined {
    let el_: Element | null = el;
  
    while (el_) {
      if (fn(el_)) {
        return el_;
      }
  
      el_ = el_.parentElement;
    }
  }
let instances: ClickOutside[] = []

type Props = {
  idException?: string,
  exceptions?: (string | Func1<MouseEvent, boolean>)[]
  handleClickOutside: Func1<MouseEvent, void>

}


export default class ClickOutside extends React.Component<Props> {
  static defaultProps = {
    exceptions: []
  };

  componentDidMount() {
    if (instances.length === 0) {
      document.addEventListener("mousedown", this.handleAll, true)
      window.parent.document.addEventListener(
        "mousedown",
        this.handleAll,
        true
      )
    }
    instances.push(this)
  }

  componentWillUnmount() {
    instances.splice(instances.indexOf(this), 1)
    if (instances.length === 0) {
      document.removeEventListener("mousedown", this.handleAll, true)
      window.parent.document.removeEventListener(
        "mousedown",
        this.handleAll,
        true
      )
    }
  }

  handleAll = (e: MouseEvent) => {

    const target: HTMLElement = e.target as HTMLElement
    if (!target) return

    instances.forEach(instance => {
      const { exceptions, handleClickOutside: onClickOutside, idException } = instance.props as Required<Props>
      let exceptionsCount = 0

      if (exceptions.length > 0) {
        const { functionExceptions, stringExceptions } = exceptions.reduce(
          (acc, exception) => {
            switch (typeof exception) {
              case "function":
                acc.functionExceptions.push(exception)
                break
              case "string":
                acc.stringExceptions.push(exception)
                break
            }

            return acc
          },
          { functionExceptions: [] as Func1<MouseEvent, boolean>[], stringExceptions: [] as string[] }
        )
        if (functionExceptions.length > 0) {
          exceptionsCount += functionExceptions.filter(
            exception => exception(e) === true
          ).length
        }

        if (exceptionsCount === 0 && stringExceptions.length > 0) {

          const el = closest(target, (e) => stringExceptions.some(ex => e.classList.contains(ex)))
          if (el) {
            exceptionsCount++
          }
        }
      }

      if (idException) {
        const target = e.target as HTMLDivElement
        if (document.getElementById(idException)!.contains(target)) {
          exceptionsCount++
        }
      }

      if (exceptionsCount === 0) {
        // eslint-disable-next-line react/no-find-dom-node
        const node = ReactDOM.findDOMNode(instance)

        if (node && !node.contains(target)) {
          onClickOutside(e)
        }
      }
    })
  };

  render() {
    return React.Children.only(this.props.children)
  }
}

Usage

<ClickOutside {...{ handleClickOutside: () => { alert('Clicked Outside') } }}>
    <div >
        <div>Breathe</div>
    </div>
</ClickOutside>

Upvotes: 1

Or Choban
Or Choban

Reputation: 1671

https://stackoverflow.com/a/42234988/9536897 solution doesn't work on mobile phones.

You can try:

  // returns true if the element or one of its parents has the class classname
  hasSomeParentTheClass(element, classname) {
    if(element.target)
    element=element.target;
    
    if (element.className&& element.className.split(" ").indexOf(classname) >= 0) return true;
    return (
      element.parentNode &&
      this.hasSomeParentTheClass(element.parentNode, classname)
    );
  }
  componentDidMount() {
    const fthis = this;

    $(window).click(function (element) {
      if (!fthis.hasSomeParentTheClass(element, "myClass"))
        fthis.setState({ pharmacyFocus: null });
    });
  }
  • On the view, gave className to your specific element.

Upvotes: 0

ruucm
ruucm

Reputation: 523

I like the @Ben Bud's answer but when there are visually nested elements, contains(event.target) not works as expected.

So sometimes it's better to calculate the clicked point is visually inside of the element or not.

Here is my React Hook code for the situation.

import { useEffect } from 'react'

export function useOnClickRectOutside(ref, handler) {
  useEffect(() => {
    const listener = (event) => {
      const targetEl = ref.current
      if (targetEl) {
        const clickedX = event.clientX
        const clickedY = event.clientY
        const rect = targetEl.getBoundingClientRect()
        const targetElTop = rect.top
        const targetElBottom = rect.top + rect.height
        const targetElLeft = rect.left
        const targetElRight = rect.left + rect.width

        if (
          // check X Coordinate
          targetElLeft < clickedX &&
          clickedX < targetElRight &&
          // check Y Coordinate
          targetElTop < clickedY &&
          clickedY < targetElBottom
        ) {
          return
        }

        // trigger event when the clickedX,Y is outside of the targetEl
        handler(event)
      }
    }

    document.addEventListener('mousedown', listener)
    document.addEventListener('touchstart', listener)

    return () => {
      document.removeEventListener('mousedown', listener)
      document.removeEventListener('touchstart', listener)
    }
  }, [ref, handler])
}

Upvotes: 1

Gregoire Cattan
Gregoire Cattan

Reputation: 595

Alternatively:

const onClickOutsideListener = () => {
    alert("click outside")
    document.removeEventListener("click", onClickOutsideListener)
  }

...

return (
  <div
    onMouseLeave={() => {
          document.addEventListener("click", onClickOutsideListener)
        }}
  >
   ...
  </div>

Upvotes: 19

ggogobera
ggogobera

Reputation: 1

I had a case when I needed to insert children into the modal conditionally. Something like this, bellow.

const [view, setView] = useState(VIEWS.SomeView)

return (
    <Modal onClose={onClose}>
      {VIEWS.Result === view ? (
        <Result onDeny={() => setView(VIEWS.Details)} />
      ) : VIEWS.Details === view ? (
        <Details onDeny={() => setView(VIEWS.Result) /> />
      ) : null}
    </Modal>
  )

So !parent.contains(event.target) doesn't work here, because once you detach children, parent (modal) doesn't contain event.target anymore.

The solution I had (which works so far and have no any issue) is to write something like this:

const listener = (event: MouseEvent) => {
   if (parentNodeRef && !event.path.includes(parentNodeRef)) callback()
}

If parent contained element from already detached tree, it wouldn't fire callback.

EDIT: event.path is new and doesn't exit in all browsers yet. Use compoesedPath instead.

Upvotes: 0

Ragav Y
Ragav Y

Reputation: 1872

I did this partly by following this and by following the React official docs on handling refs which requires react ^16.3. This is the only thing that worked for me after trying some of the other suggestions here...

class App extends Component {
  constructor(props) {
    super(props);
    this.inputRef = React.createRef();
  }
  componentWillMount() {
    document.addEventListener("mousedown", this.handleClick, false);
  }
  componentWillUnmount() {
    document.removeEventListener("mousedown", this.handleClick, false);
  }
  handleClick = e => {
    /*Validating click is made inside a component*/
    if ( this.inputRef.current === e.target ) {
      return;
    }
    this.handleclickOutside();
  };
  handleClickOutside(){
    /*code to handle what to do when clicked outside*/
  }
  render(){
    return(
      <div>
        <span ref={this.inputRef} />
      </div>
    )
  }
}

Upvotes: 6

Anjan Poudel
Anjan Poudel

Reputation: 1

This is the method that suits me the best to make a dropdown menu:

handleClick = () => {
    document.getElementById("myDrop").classList.toggle("showing");
}

render() {

    return (
        <div className="courses">
            <div class="dropdownBody">
                <button onClick={this.handleClick} onBlur={this.handleClick} class="dropbtn">Dropdown</button>
                <div id="myDrop" class="dropdown-content">
                    <a href="#home">Home</a>
                    <a href="#about">About</a>
                    <a href="#contact">Contact</a>
                </div>
            </div>
        </div>
    )
}

Upvotes: -2

Related Questions