Search code examples
reactjstypescriptnext.jsuse-context

React context (hooks) not updating all references


I am trying to implement a modal with a custom close callback. I did this with an useState hook that stores this method and executes it in an, already define, closeModal(). My problem is when I try to use the closeModal from context, the callback persists with the initial value, not the updated one.

function ModalProvider({ children }) {
  const [component, setComponent] = useState<React.ReactNode>(null);
  const [styles, setStyles] = useState<React.CSSProperties>(null);
  const [onClose, setOnClose] = useState<() => void>(null);

  const openModal = ({
    component,
    containerStyles,
    onClose,
  }: OpenModalProps) => {
    setComponent(component);
    setStyles(containerStyles);
    setOnClose(() => onClose);
  };

  const closeModal = () => {
    onClose?.();

    setComponent(null);
    setStyles(null);
    setOnClose(null);
  };

  return (
    <ModalContext.Provider value={{ component, styles, openModal, closeModal }}>
      {children}
    </ModalContext.Provider>
  );
}

So here I using in two points, the button and the custom hook useClickOutside. When I click the button it works properly but when the event is catch from the hook what happen is that the modal closes but the onClose is not executed because is null.

function Modal() {
  const { component, styles, closeModal } = useContext(ModalContext);
  const contentRef = useRef<HTMLDivElement>(null);

  const [stopScrolling, continueScrolling] = useStopScrolling();

  useClickOutside(contentRef, closeModal);
  useEffect(() => {
    if (!component) continueScrolling();
    if (component) stopScrolling();
  }, [component]);

  if (!component) return <></>;

  return (
    <>
      <S.ModalWrapper>
        <section style={styles} ref={contentRef}>
          <button onClick={closeModal}>x</button>
          {component}
        </section>
      </S.ModalWrapper>
    </>
  );
}
function useClickOutside(ref, onClickOutside: () => void) {
  const handleClickOutside = (event) => {
    if (ref.current && !ref.current.contains(event.target)) {
      onClickOutside();
    }
  };

  useEffect(() => {
    document.addEventListener("mousedown", handleClickOutside);

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

I use this method on other app points and also fails but I think this could be enough to found the problem. Thanks.

Edit

This is how I invoke the modal

      openModal({
        component: <QuickView product={product} close={closeModal} />,
        containerStyles: {
          width: "80%",
          maxWidth: "1000px",
          height: "auto",
          backgroundColor: "transparent",
        },
        onClose: () => {
          const as = router.asPath;
          router.push(as, as, { shallow: true });
        },
      });

Solution

  • Since there are multiple places in your code where closeModal is captured in closures without being declared a dependency, it has the potential to be invoked after becoming stale.

    In that case it would be easier to make sure that it never has a stale reference to onClose, and you can do that by defining it with useRef() instead of useState():

      const [component, setComponent] = useState<React.ReactNode>(null);
      const [styles, setStyles] = useState<React.CSSProperties>(null);
      // const [onClose, setOnClose] = useState<() => void>(null);
      const onCloseRef = useRef<() => void>(null);
    
      const openModal = ({
        component,
        containerStyles,
        onClose,
      }: OpenModalProps) => {
        setComponent(component);
        setStyles(containerStyles);
        // setOnClose(() => onClose);
        onCloseRef.current = onClose;
      };
    
      const closeModal = () => {
        // onClose?.();
        onCloseRef.current?.();
    
        setComponent(null);
        setStyles(null);
        // setOnClose(null);
        onCloseRef.current = null;
      };