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
You’re defining the
clickOutsideHandler
function as anuseState
, 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)
That said, instead off creating a
open
andclose
function, you can use an arrow function in setState handler to get the current value, and then toggle it, eg: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 declaringclickOutsideHandler
state.The
clickOutsideHandler
state is never updated to re-enclose the currentisOpen
state value, so it always has the initial falseisOpen
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 theuseEffect
hook’s dependencies so the effect is properly setup.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 theuseEffect
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 inclickOutsideHandler
since it can only ever be called ifisOpen
was true and the event listener was added.