Saman
Saman

Reputation: 349

React, click outside event fire right after the open event, preventing the modal from being displayed

I have a problem. I added an onClick event to an element. Its event handler's work is to change a state. After that state changed, I display a popup. I get access to that popup using useRef hook. Then I add a click event to the document and its event handler work is to check that user clicked outside the popup or not.

But the problem is here: when user clicks on the element, immediately the added event handler to the document will be executed! But how? look at these steps and you'll understand my point better:

user clicked on the show popup button --> onClick event handler executed --> the state changed --> an other event added to the document(for outside click purpose) --> immediately the event handler of click event on document executed (all these happen when you click on show popup button just once!!).

Option popup component:

import { useRef } from "react";
import useAxis from "../../hooks/useAxis";
import useOutSideClick from "../../hooks/useOutSideClick";

const Options = (props) => {
  const { children, setShowOptions, showOptions } = props;
  const boxRef = useRef();

  const { childrens, offset } = useAxis(children, {
    /* add an onClick to this components children(it has just one child and it is the open 
    popup button)*/
    onClick: () => {
      console.log("test1");
      //show the options popup
      setShowOptions(!showOptions);
    },
  });
  
  //close the popup if user clicked outside the popup
  useOutSideClick(boxRef, () => {
    console.log("test2");
    //close the popup
    setShowOptions((prevState) => !prevState);
  }, showOptions);

  return (
    <>
      {showOptions && (
        <div
          ref={boxRef}
          className="absolute rounded-[20px] bg-[#0d0d0d] border border-[#1e1e1e] border-solid w-[250px] overflow-y-auto h-[250px]"
          style={{ left: offset.x + 25 + "px", top: offset.y + "px" }}
        ></div>
      )}
      {childrens}
    </>
  );
};

export default Options;

useOutSideClick custom hook:

import { useEffect } from "react";

//a custom hook to detect that user clicked
const useOutSideClick = (ref, outSideClickHandler, condition = true) => {
  useEffect(() => {
    if (condition) {
      const handleClickOutside = (event) => {
        console.log("hellloooo");
        //if ref.current doesnt contain event.target it means that user clicked outside
        if (ref.current && !ref.current.contains(event.target)) {
          outSideClickHandler();
        }
      };

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

export default useOutSideClick;

useAxis custom hook:

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

const useAxis = (children, events) => {
  const childRef = useRef();
  const [offset, setOffset] = useState({
    x: "",
    y: "",
  });

  useEffect(() => {
    Object.keys(events).forEach((event) => {
      let eventHandler;
      let callBack = events[event];
      if (event === "onClick" || event === "onMouseEnter") {
        eventHandler = (e) => {
          callBack();
        };
        events[event] = eventHandler;
      }
    });
  }, [JSON.stringify(events)]);

  //setting mouse enter and leave event for the components children
  const childrens = React.Children.map(children, (child) => {
    return React.cloneElement(child, {
      ...events,
      ref: childRef,
    });
  });

  //initialize the position of child component at the first render
  useEffect(() => {
    setOffset(() => {
      return {
        x: childRef.current.offsetLeft,
        y: childRef.current.offsetTop,
      };
    });
  }, []);

  return { childrens, offset };
};

export default useAxis;

the button(actually the element) component:

//basic styles for icons
const iconsStyles = "w-[24px] h-[24px] transition duration-300 cursor-pointer";

    const NavBar = () => {
      const [showOptions, setShowOptions] = useState(false);
      return (
          <Options
            setShowOptions={setShowOptions}
            showOptions={showOptions}
          >
            //onClick will be applied to this div only
            <div
            >
              <TooltipContainer tooltipText="options">
                <div>
                  <DotsHorizontalIcon
                    className={`${iconsStyles} ${
                      showOptions
                        ? "text-[#fafafa]"
                        : "text-[#828282] hover:text-[#fafafa]"
                    }`}
                  />
                </div>
              </TooltipContainer>
            </span>
          </Options>
       //there are some other items that theres no need to be here
      );
    };
    
    export default NavBar;

You can see my codes and the part of my app that you need to see in this CodeSandbox link. so what should I do to avoid executing the event handler(for outside click purpose) immediately after the event added to the document by clicking on open popup button for the first time?

Upvotes: 1

Views: 1623

Answers (3)

umbriel
umbriel

Reputation: 751

Equally you can set capture to equal true in your addEventListeners {capture: true}

document.addEventListener("click", handleClickOutside, true);
return () => {
   document.removeEventListener("click", handleClickOutside, true);
};

Upvotes: 1

Youssouf Oumar
Youssouf Oumar

Reputation: 46191

Problem

You are returning the below JSX inside Options.jsx. Where childrens contains the button that opens the modal. If you look at your JSX, this button will not be rendered as a child of the div with boxRef, which is why this if (ref.current && !ref.current.contains(event.target)) inside useOutSideClick will always pass, so it closes the modal right after.

return (
    <>
      {showOptions && (
        <div
          ref={boxRef}
          className="absolute rounded-[20px] bg-[#0d0d0d] border border-[#1e1e1e] border-solid w-[250px] overflow-y-auto h-[250px]"
          style={{ left: offset.x + 25 + "px", top: offset.y + "px" }}
        ></div>
      )}
      {childrens}
    </>
  );

Solution

A quick solution is to change the above return with the below one. Notice the ref is being added to a outer div, so the button for opening the modal is rendered as its child.

 return (
    <div ref={boxRef}>
      {showOptions && (
        <div
          className="absolute rounded-[20px] bg-[#0d0d0d] border border-[#1e1e1e] border-solid w-[250px] overflow-y-auto h-[250px]"
          style={{ left: offset.x + 25 + "px", top: offset.y + "px" }}
        ></div>
      )}
      {childrens}
    </div>
  );

Working CodeSandbox here.

Upvotes: 1

Dean
Dean

Reputation: 551

I believe the event of the on click bubbles up, while the state is already toggled, and the ref is initialized, therefore at some point reaching the documents, whose listener is now registered and then called. to stop this behaviour you need to call e.stopPropagation() in useAxis for the click event. However the signed up listener to the button is not the one you would expect. It will sign up the listener you passed to the useAxis hook, instead of the modified one in useAxis itself. To circumvent this, put the code from inside the useEffect in useAxis simply in the render function. If you then call e.stopPropagation() on the event, it should work. Here the final partial code in useAxis:

  // now correctly updates events in time before cloning the children
  Object.keys(events).forEach((event) => {
    let eventHandler;
    let callBack = events[event];
    if (event === "onClick" || event === "onMouseEnter") {
      eventHandler = (e) => {
        // stop the propagation of the event
        e.stopPropagation();
        callBack();
      };
      events[event] = eventHandler;
      console.log("inner events", events);
    }
  });

  //setting updated mouse enter and leave event for the components children
  const childrens = React.Children.map(children, (child) => {
    console.log(events);
    return React.cloneElement(child, {
      ...events,
      ref: childRef
    });
  });

Upvotes: 2

Related Questions