Search code examples
javascriptreactjseventsevent-bubblingevent-capturing

Clicking a child component affects parent in an unexpected way


I have two components, Container and Item.

  • Container contains Item.
  • Item contains a button and a div.

These components have the following behaviors:

  • Container: When I click outside of Container it should disappear, I'm achieving this by using a custom hook that detects clicks outside of components. This works just fine.
  • Item: When I click in the div which is inside Item it should disappear, I'm achieving this by setting a boolean state. This also works but the problem here is that Container also disappears.

Container

const Container = ({ setDisplay }) => {
  const containerRef = useRef(null);
  useClickOutside(containerRef, () => {
    //code to make Container disappear that is not relevant for the issue
    setDisplay(false)
  });
  return (
    <div ref={containerRef} className='container'>
      <Item />
    </div>
  );
};

Item

const Item = () => {
  const [displayItem, setDisplayItem] = useState(false);
  return (
    <div>
      <button onClick={() => setDisplayItem(true)}>Show Item's content</button>
      {displayItem && (
        <div
          className='item-content'
          onClick={() => setDisplayItem(false)}
        />
      )}
    </div>
  );
};

useClickOutside

const useClickOutside = (ref, handler) => {
  useEffect(() => {
    const trigger = e => {
      if (!(ref?.current?.contains(e.target))) handler();
    }
    document.addEventListener('click', trigger);
    return () => document.removeEventListener('click', trigger);
  }, [handler, ref])
}

Why is this happening and how can I prevent it?

Note: I have to use that hook.


Solution

  • Both the listeners are being attached to the bubbling phase, so the inner ones trigger first.

    When the item is shown, and when it's clicked, this runs:

    <div
      className='item-content'
      onClick={() => setDisplayItem(false)}
    >item content</div>
    

    As a result, before the event propagates outward, setDisplayItem(false) causes this .item-content element to be removed from the DOM. See here, how the parent no longer exists afterwards:

    const Container = ({ setDisplay }) => {
      const containerRef = React.useRef(null);
      useClickOutside(containerRef, () => {
        //code to make Container disappear that is not relevant for the issue
        console.log('making container disappear');
      });
      return (
        <div ref={containerRef} className='container'>
          container
          <Item />
        </div>
      );
    };
    const Item = () => {
      const [displayItem, setDisplayItem] = React.useState(false);
      return (
        <div>
          <button onClick={() => setDisplayItem(true)}>Show Item's content</button>
          {displayItem && (
            <div
              className='item-content'
              onClick={() => setDisplayItem(false)}
            >item content</div>
          )}
        </div>
      );
    };
    const useClickOutside = (ref, handler) => {
      React.useEffect(() => {
        const trigger = e => {
          console.log(e.target.parentElement);
          if (!(ref.current.contains(e.target))) handler();
        }
        document.addEventListener('click', trigger);
        return () => document.removeEventListener('click', trigger);
      }, [handler, ref])
    }
    
    
    ReactDOM.render(<Container />, document.querySelector('.react'));
    <script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
    <div class='react'></div>

    You can fix this by changing useClickOutside to also check whether the node is connected or not. If it's not connected, the element is no longer in the DOM due to a state change and rerender - so the click that was made wasn't definitely outside the ref.current, so the handler shouldn't run.

    const trigger = e => {
      const { current } = ref;
      if (e.target.isConnected && !current.contains(e.target)) {
    

    const Container = ({ setDisplay }) => {
      const containerRef = React.useRef(null);
      useClickOutside(containerRef, () => {
        //code to make Container disappear that is not relevant for the issue
        console.log('making container disappear');
      });
      return (
        <div ref={containerRef} className='container'>
          container
          <Item />
        </div>
      );
    };
    const Item = () => {
      const [displayItem, setDisplayItem] = React.useState(false);
      return (
        <div>
          <button onClick={() => setDisplayItem(true)}>Show Item's content</button>
          {displayItem && (
            <div
              className='item-content'
              onClick={() => setDisplayItem(false)}
            >item content</div>
          )}
        </div>
      );
    };
    const useClickOutside = (ref, handler) => {
      React.useEffect(() => {
        const trigger = e => {
          const { current } = ref;
          if (e.target.isConnected && !current.contains(e.target)) {
            console.log(current.parentElement);
            handler();
          }
        }
        document.addEventListener('click', trigger);
        return () => document.removeEventListener('click', trigger);
      }, [handler, ref])
    }
    
    
    ReactDOM.render(<Container />, document.querySelector('.react'));
    <script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
    <div class='react'></div>