ClickAwayListener doesn't fire when clicking on a link/button to navigate to other route

Viewed 2771

I'm using Material-UI ClickAwayListener component with the existing code that used react-router. The button is outside of the <ClickAwayListener> ... </ClickAwayListener> and so I expected the onClickAway to fire before navigating to other route. But it didn't

Below are the replicate of my code, to some extent to demonstrate what I mean

function Component(){

  const handleClickAway = () => {
    // Do something here
  }

  return (
  <>
    <button>
      <Link to="/other-route">Click here </Link>
    </button>
    // Some other element here
    <ClickAwayListener onClickAway={handleClickAway}>
      <div>
        // Content
      </div>
    </ClickAwayListener>
  </>
  )
}

So if I click any where that is outside of <ClickAwayListener> and <button> the handleClickAway fired, but if I click onto the <button> which contains the like to other route it doesn't.

I tried to look onto source code of ClickAwayListener and this part, I believe, is responsible for detecting the click

 React.useEffect(() => {
    if (mouseEvent !== false) {
      const mappedMouseEvent = mapEventPropToEvent(mouseEvent);
      const doc = ownerDocument(nodeRef.current);

      doc.addEventListener(mappedMouseEvent, handleClickAway);

      return () => {
        doc.removeEventListener(mappedMouseEvent, handleClickAway);
      };
    }

    return undefined;
  }, [handleClickAway, mouseEvent]);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.1/umd/react-dom.production.min.js"></script>

As far as I can understand, this part will, first add an event listener to click event when the component mount/re-render and will remove that listener before the component unmount (default behavior for useEffect()). But if this is the case, then before the component get unmount by any event that involved clicking outside of the area of ClickAwayListener, the onClickAway listener should be fired because the listener still attach to the click event.

So in short this is the behavior I expect:

Button click --> onClickAway fire --> component get unmount --> go to new route --> clean-up code of useEffect() run --> the listener get removed

But this is what happen so far

Button click --> component get umount --> go to new route --> clean-up code of useEffect() run --> the listener get removed

Can someone help explain to me why this happen?

5 Answers

ClickAwayListener component works by attaching the event listener to the document, when a mouse event fires, it fires onClickAway only when the mouse event is not inside the element.

The Link component from react-router-dom essentially renders something like this:

<a onClick={() => navigate()}>click</a>

When you click the link and call navigate(), React unmounts the current component and mounts the next page component in the next frame. But the thing is, document handlers are only processed after the next re-render, at that point, the event handler from the ClickAwayListener had already been removed since it was unmounted, so nothing get called.

The problem can be solved by waiting until after the next re-render when the handlers from document have been called.

<button
  onClick={() => {
    setTimeout(() => {
      history.push("/2");
    });
  }}
>

Live Demo

Edit 64531264/clickawaylistener-doesnt-fire-when-clicking-on-a-link-button-to-navigate-to-oth

An issue related to your question was opened in September 2019 in the Material UI repository where the Clickawaylistener doesn't fire for both buttons and links. However as recently as May 2020, the issue does not appear to have been adequately addressed. I recommend opening a new issue in the repository to confirm that this isn't a bug as it relates to buttons.

As a potential workaround in the meantime, and depending on your use case, you could either use a <Link> or <Button> component with an onClick prop:

import React from "react";
import ClickAwayListener from "@material-ui/core/ClickAwayListener";
import { Link, useHistory } from "react-router-dom";
import Button from "@material-ui/core/Button";

export default function Home() {
  let history = useHistory();

  const handleClickAway = () => {
    console.log("Clicked Away");
  };

  function handleClick() {
    handleClickAway();
    history.push("/about");
  }

  return (
    <div>
      <Link to="/about" onClick={handleClickAway}>
        Link
      </Link>
      <Button type="button" onClick={handleClick}>
        Button
      </Button>
      <ClickAwayListener onClickAway={handleClickAway}>
        <div>Home Page</div>
      </ClickAwayListener>
    </div>
  );
}

I've also included a working example on Codesandbox.

 Working Example

In the issue that Tejogol linked, a commenter recommended use of these props on the ClickAwayListener component, as it allows the component to catch the touch event at its start, before the link does.

<ClickAwayListener mouseEvent="onMouseDown" touchEvent="onTouchStart">

I was having the same issue as you, and this worked for me! It was also the least intrusive, as I didn't want to mess with my react-router-dom code.

I was able to solve this issue by using this function instead of the material-ui ClickAwayListener: https://github.com/streamich/react-use/blob/master/src/useClickAway.ts

import { RefObject, useEffect, useRef } from 'react';
import { off, on } from './util';

const defaultEvents = ['mousedown', 'touchstart'];

const useClickAway = <E extends Event = Event>(
  ref: RefObject<HTMLElement | null>,
  onClickAway: (event: E) => void,
  events: string[] = defaultEvents
) => {
  const savedCallback = useRef(onClickAway);
  useEffect(() => {
    savedCallback.current = onClickAway;
  }, [onClickAway]);
  useEffect(() => {
    const handler = (event) => {
      const { current: el } = ref;
      el && !el.contains(event.target) && savedCallback.current(event);
    };
    for (const eventName of events) {
      on(document, eventName, handler);
    }
    return () => {
      for (const eventName of events) {
        off(document, eventName, handler);
      }
    };
  }, [events, ref]);
};

export default useClickAway;

I didn't want to import the library as a dependency so I created a hook out of this code and copied the util functions into it

const on = (obj: any, ...args: any[]) => obj.addEventListener(...args);
const off = (obj: any, ...args: any[]) => obj.removeEventListener(...args);

An example of the hook in use is provided here: https://streamich.github.io/react-use/?path=/story/ui-useclickaway--demo

Most of the answers here offer solutions, but none went into detail about why this happened. So this is what I think is happening

At the time I posted this question, I was using React 17. For any React version below 18, there is one "bug" that appears when using addEventListener or any async action like async/await or Promise. This is discussed in GitHub React 18 discussion

You can try out this code in React version below 18 and you should observe similar result

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

export default function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log("Test in useEffect " + count);
  });

  useEffect(() => {
    console.log("Another one useEffect");
  });

  useEffect(() => {
    const button = document.getElementById("buttonId");
    button.addEventListener("click", handleClick);
    return () => {
      button.removeEventListener("click", handleClick);
    };
  }, []);

  const handleClick = () => {
    console.log("First print Count is " + count);

    setCount(count + 1);
    setCount(count + 100);
    console.log("Second print Count is " + count);
  };

  return (
    <>
      <div>Count is: {count} </div>
      <button id="buttonId">Click</button>
    </>
  );
}

Related