skip to Main Content

I’m trying to add and remove widow event listeners on opening and closing a component.

Issue is that isOpen in the handler is always at it’s inital condition and doesn’t use the latest state. How can I tackle this problem? I’ve seen a lot of solutions using useEffect(() => { .. }, []); but that won’t work since I want to be able to add and remove the listeners at different times than on mount and unmount.

I’ve also tried tu use useCallback but it has the same problem as useEffect

Here is my current code:

import { forwardRef, Ref, useEffect, useImperativeHandle, useRef } from 'react';
import { useState } from 'react';

export const Example = (props) => {

    const userMenuRef = useRef(null);

    return (
        <>
            <button onClick={() => userMenuRef.current?.open()}>Open Popover</button>
            <Popover ref={userMenuRef} />
        </>
    );
}

type Props = {
};

export const Popover = forwardRef((props: Props, ref: Ref<any>) => {

    const [isOpen, setIsOpen] = useState(false);
    const [clickOutsideHandler] = useState(() =>
        (evt: MouseEvent): void => {
            console.log('!isOpen: ' + isOpen); // <-- issue is that `isOpen` is always false
            if (!isOpen || popoverRef.current.contains(evt.target as Node)) { return; }   // Clicked on one of the own children
            
            close();
        }
    );

    const popoverRef = useRef(null);

    const open = (): void => {
        if (isOpen) { return; }
        setIsOpen(true);
    }

    const close = (): void => {
        if (!isOpen) { return; }
        setIsOpen(false);
    }

    useImperativeHandle(ref, () => ({
        open,
    }));

    useEffect(() => {
        if (isOpen) {
            window.requestAnimationFrame(() => {    // Wait 1 tick; otherwise a potential click that opend the element will immediately trigger a click-outside event
                document.addEventListener('click', clickOutsideHandler);
            });
        }
        else {
            document.removeEventListener('click', clickOutsideHandler);
        }
    }, [isOpen]);

    return (
        <>
            {(isOpen) &&
                <div ref={popoverRef}>
                    <div onClick={() => { close(); }}>Some Content here</div>
                </div>
            }
        </>
    );
});

Question is: how can I make the handler presist while still being able to access the correct state inside it?

2

Answers


  1. You’re defining the clickOutsideHandler function as an useState, you should just make that an actual function since it’s not using any state logic.


    I’d change it to something like so (without ts)

    const clickOutsideHandler = (evt) => {
        if (!isOpen || popoverRef.current.contains(evt.target)) { return; }   // Clicked on one of the own children
        close();
    };
    

    That said, instead off creating a open and close function, you can use an arrow function in setState handler to get the current value, and then toggle it, eg:

    const toggleOpenState = () => setIsOpen(cur => !cur);
    
    Login or Signup to reply.
  2. Issue

    This is a Javascript Closure issue.

    You are defining a function that closes over the isOpen state value from the initial render cycle when declaring clickOutsideHandler state.

    const [isOpen, setIsOpen] = useState(false);
    
    const [clickOutsideHandler] = useState(() =>
      (evt: MouseEvent): void => {
        console.log('!isOpen: ' + isOpen);
        if (!isOpen || popoverRef.current.contains(evt.target as Node)) {
          return;
        } // Clicked on one of the own children
            
        close();
      }
    );
    

    The clickOutsideHandler state is never updated to re-enclose the current isOpen state value, so it always has the initial false isOpen state value.

    Initial Solution

    Instead of using React state to store a callback, it’s more common to use the useCallback hook (i.e. useState + useEffect to update callback -> useCallback) with appropriate dependencies to properly close over values and provide a stable callback reference.

    Example:

    Note that the clickOutsideHandler will also need to be included in the useEffect hook’s dependencies so the effect is properly setup.

    const [isOpen, setIsOpen] = useState(false);
    
    const clickOutsideHandler = useCallback(
      (evt: MouseEvent): void => {
        if (!isOpen || popoverRef.current.contains(evt.target as Node)) {
          return;
        } // Clicked on one of the own children
            
        close();
      },
      [isOpen]
    );
    
    useEffect(() => {
      if (isOpen) {
        window.requestAnimationFrame(() => {
          document.addEventListener('click', clickOutsideHandler);
        });
      } else {
        document.removeEventListener('click', clickOutsideHandler);
      }
    }, [clickOutsideHandler, isOpen]);
    

    Actual Solution

    The above solution doesn’t remove previously added event listeners. The effect should return a cleanup function to properly remove previously added event listeners. Move the clickOutsideHandler declaration into the useEffect hook callback so it’s an internal reference for the cleanup function.

    Only add the event listener when isOpen is truthy, and only remove the listener in the cleanup function.

    You can also remove the isOpen check in clickOutsideHandler since it can only ever be called if isOpen was true and the event listener was added.

    const [isOpen, setIsOpen] = useState(false);
    
    useEffect(() => {
      const clickOutsideHandler = (evt: MouseEvent): void => {
        if (popoverRef.current.contains(evt.target as Node)) {
          return;
        } // Clicked on one of the own children
            
        close();
      };
    
      if (isOpen) {
        window.requestAnimationFrame(() => {
          document.addEventListener('click', clickOutsideHandler);
        });
      }
    
      return () => {
        document.removeEventListener('click', clickOutsideHandler);
      };
    }, [isOpen]);
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search