Pasha Zherko
Pasha Zherko

Reputation: 23

useEffect not run when dependency value with ref on an element changed

export const useOutsideElementClick = (
    element: HTMLElement | null,
    handler: (target: EventTarget | null) => void,
    relativeOutsideElementSelector?: string,
) => {
    useLayoutEffect(() => {
        const handleClickOutside = (event: MouseEvent) => {
            if (element && !element.contains(event.target as Node)) {
                handler(event.target);
            }
        };
        const modal = relativeOutsideElementSelector
            ? (document.querySelector(relativeOutsideElementSelector) as HTMLElement)
            : (document as unknown as HTMLElement);
        modal?.addEventListener('click', handleClickOutside);
        return () => {
            modal?.removeEventListener('click', handleClickOutside);
        };
    }, [element]);
};

when I include this hook in component, when there is a div with ref which disappears and show with condition when some of value with setState change useEffect doesn't run again. I check "element" value in useOutsideElementClick block with console.log. It changes from null to some HTMLDivElement, but useEffect doesn't react

Upvotes: 1

Views: 108

Answers (1)

Ori Drori
Ori Drori

Reputation: 191976

Refactor the hook, so it would return a setElement function instead of expecting an element that you get from a ref. Ref changes don't cause re-render, and that's why the element change is ignored.

The setElement is return from useState. Calling setElement would cause the a re-render, and would change the element that would trigger the useEffect:

export const useOutsideElementClick = (
    handler: (target: EventTarget | null) => void,
    relativeOutsideElementSelector?: string,
) => {
    const [element, setElement] = useState<HTMLElement | null>(null);

    useLayoutEffect(() => {
        if(!element) return; // skip if no element
        
        const handleClickOutside = (event: MouseEvent) => {
            if (element.contains(event.target as Node)) {
                handler(event.target);
            }
        };
        
        const modal = relativeOutsideElementSelector
            ? (document.querySelector(relativeOutsideElementSelector) as HTMLElement)
            : (document as unknown as HTMLElement);
            
        modal?.addEventListener('click', handleClickOutside);
        
        return () => {
            modal?.removeEventListener('click', handleClickOutside);
        };
    }, [element, handler, relativeOutsideElementSelector]);
    
    const return setElement;
};

Usage - you use the setElement as function ref, so when the element is rendered, it would call the setElement and trigger the hook.

Note: the element, handler, and relativeOutsideElementSelector. are dependencies of the hook's useEffect. This means that you need to memoize the handler to prevent the useEffect from being trigger by the handler being re-created on renders.

const hander = `useCallback(...)`; memoize the handler
const setElement = useOutsideElementClick(handler);

return (
  <div ref={setElement} />
)

You can avoid the need to memoize the handler by passing it via ref to the useEffect's callback:

export const useOutsideElementClick = (
    handler: (target: EventTarget | null) => void,
    relativeOutsideElementSelector?: string,
) => {
    const [element, setElement] = useState<HTMLElement | null>(null);
    const handlerRef = useRef(handler);

    useEffect(() => { handlerRef.current = handler; }); // set the handlerRef

    useLayoutEffect(() => {
        if(!element) return; // skip if no element

        const handleClickOutside = (event: MouseEvent) => {
            if (element.contains(event.target as Node)) {
                handler.current(event.target);
            }
        };

        const modal = relativeOutsideElementSelector
            ? (document.querySelector(relativeOutsideElementSelector) as HTMLElement)
            : (document as unknown as HTMLElement);

        modal?.addEventListener('click', handleClickOutside);

        return () => {
            modal?.removeEventListener('click', handleClickOutside);
        };
    }, [element, relativeOutsideElementSelector]);

    const return setElement;
};

Upvotes: 1

Related Questions