Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

React: Stop click event propagation when using mixed React and DOM events

We have a menu. If menu is open, We should be able to close it by clicking anywhere:

class Menu extends Component {
  componentWillMount() {
    document.addEventListener("click", this.handleClickOutside);
  }

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

  openModal = () => {
    this.props.showModal();
  };

  handleClickOutside = ({ target }) => {
    const { displayMenu, toggleMenu, displayModal } = this.props;

    if (displayMenu) {
      if (displayModal || this.node.contains(target)) {
        return;
      }
      toggleMenu();
    }
  };
  render() {
    return (
      <section ref={node => (this.node = node)}>
        <p>
          <button onClick={this.openModal}>open modal</button>
        </p>
        <p>
          <button onClick={this.openModal}>open modal</button>
        </p>
        <p>
          <button onClick={this.openModal}>open modal</button>
        </p>
      </section>
    );
  }
}

From menu, we can open a modal by clicking on button inside menu. We can close modal in two ways: by clicking close modal button inside modal, or on click on bakcdrop/overlay outside the modal:

class Modal extends Component {
  hideModal = () => {
    this.props.hideModal();
  };

  onOverlayClick = ({ target, currentTarget }) => {
    if (target === currentTarget) {
      this.hideModal();
    }
  };

  render() {
    return (
      <div className="modal-container" onClick={this.onOverlayClick}>
        <div className="modal">
          <button onClick={this.hideModal}>close modal</button>
        </div>
      </div>
    );
  }
}

And now, when menu and modal is open, on close modal click or modal overlay click I want to close only modal, menu should be still open. Only on second click (while modal is closed). At first glance it look pretty clear and easy, this condition should be responsible for that:

if (displayModal || this.node.contains(target)) {
  return;
}

If displayModal is true, nothing should happen. I'ts do not work, cause in my case, when you click at the close modal button or overlay, hideModal will be done faster than toggleMenu, and when we call handleClickOutside displayModal will already have false.

Full test case with open menu and modal at the start:

https://codesandbox.io/s/reactredux-rkso6

like image 622
sosick Avatar asked Aug 23 '19 23:08

sosick


People also ask

How do you stop a propagation of an event in React?

event.stopPropagation() To use this: Make sure to pass the event object as a parameter. Use the stopPropagation method on the event object above your code within your event handler function.

How do you stop click event in React JS?

Just pass the event into the original click handler and call preventDefault(); .

How do you stop synthetic events in React?

in a event handler function in our React component to stop the propagation of React synthetic events and native events. We use ev. stopPropagation(); to stop the propagation of React synthetic events.

How do you stop Event bubbling?

The event. stopPropagation() method stops the bubbling of an event to parent elements, preventing any parent event handlers from being executed. Tip: Use the event. isPropagationStopped() method to check whether this method was called for the event.


1 Answers

This is gonna be a bit longer, as I have investigated in similar issue recently. If you don't want to read everything, just have a look at the solutions.

Solutions

Two solutions come to my mind - the first is the easy fix, the second is cleaner, but requires an additional click handler component.

1.) Easy fix

In Modal.js onOverlayClick, add stopImmediatePropagation like this:

  onOverlayClick = e => {
    // this is to stop click propagation in the react event system
    e.stopPropagation();
    // this is to stop click propagation to the native document click
    // listener in Menu.js
    e.nativeEvent.stopImmediatePropagation();
    if (e.target === e.currentTarget) {
      this.hideModal();
    }
  };

On document, there are two click listener registered: a) the first is the top level listener of React b) your click listener in Menu.js. With e.nativeEvent you get the native DOM event wrapped by React. stopImmediatePropagation will cancel the second listener - and prevents closing of the menu, when you just want to close the modal. You can read more under explanation.

Codesandbox

2.) The clean one

With this solution, you can just use event.stopPropagation. All event handling (incl. the outside click handler) is done by React, so you don't have to use document.addEventListener("click",...) anymore. The click-handler.js down under will be just some proxy that catches all click events at the top level and forwards them in the React event system to your registered components.

Create click-handler.jsx:

import React from "react";

export const clickListenerApi = { addClickListener, removeClickListener };

export const ClickHandler = ({ children }) => {
  return (
    <div
      // span click handler over the whole viewport to catch all clicks
      style={{ minHeight: "100vh" }}
      onClick={e => {
        clickListeners.forEach(cb => cb(e));
      }}
    >
      {children}
    </div>
  );
};

// state of registered click listeners
let clickListeners = [];

function addClickListener(cb) {
  clickListeners.push(cb);
}

function removeClickListener(cb) {
  clickListeners = clickListeners.filter(l => l !== cb);
}

Menu.js:

class Menu extends Component {

  componentDidMount() {
    clickListenerApi.addClickListener(this.handleClickOutside);
  }

  componentWillUnmount() {
    clickListenerApi.removeClickListener(this.handleClickOutside);
  }

  openModal = e => {
    // This click shall not close the menu,
    // so don't propagate the event to our clickListener API.
    e.stopPropagation();
    const { showModal } = this.props;
    showModal();
  };

  render() {... }
}

index.js:

const App = () => (
  <Provider store={store}>
    <ClickHandler>
      <Page />
    </ClickHandler>
  </Provider>
);

Codesandbox

Explanation:

When you have both modal dialog and menu open and click once outside the modal, then with your current code the behavior is correct - both elements are closed. That is because in the DOM document has already received the click event and prepares itself to invoke your handleClickOutside click handler in Menu. So you have no chance anymore to cancel it via e.stopPropagation() in onOverlayClick callback of Modal.

In order to understand the order of both firing click events, we have to comprehend that React has its own synthetic Event Handling system (1, 2). The main point here is that React uses top level event delegation and adds one single listener to document in the DOM for all event types.

Let's say you have a button <button id="foo" onClick={...}>Click it</button> somewhere in the DOM. When you click the button, it triggers a regular click event in the browser, that bubbles up to document and further until it reaches DOM root. React catches this click event with its single listener at document, and then internally traverses its virtual DOM again (similar to capture and bubble phase of the native DOM) and collects all relevant click callbacks that you have set with onClick={...} in your components. So your button onClick will be found and invoked later on.

Here is the interesting part: by the time React deals with the click events (which are now synthetic React events), the native click event had already gone through the full capture/bubbling cycle in the DOM and doesn't exist in the native DOM anymore! That is the reason, why a mix of native click handlers (document.addEventListener) and React onEvent attributes in the components' JSX sometimes is so hard to handle and unpredictable. React event handlers should always be preferred.

Links to read on:

  • Understanding React's Synthetic Event System (also the linked article with it)
  • ReactJS SyntheticEvent stopPropagation() only works with React events?
  • https://fortes.com/2018/react-and-dom-events/

Hope, it helps.

like image 160
ford04 Avatar answered Oct 20 '22 01:10

ford04