Я пытаюсь добавить и удалить прослушиватели событий вдовы при открытии и закрытии компонента.
Проблема в том, что isOpen
в обработчике всегда находится в исходном состоянии и не использует последнее состояние. Как я могу решить эту проблему? Я видел много решений, использующих useEffect(() => { .. }, []);
, но это не сработает, поскольку я хочу иметь возможность добавлять и удалять прослушиватели в разное время, а не при монтировании и размонтировании.
Я также пробовал использовать useCallback
, но возникла та же проблема, что и useEffect
.
Вот мой текущий код:
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.info('!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>
}
</>
);
});
Вопрос в следующем: как я могу заставить обработчик сохраняться, сохраняя при этом доступ к правильному состоянию внутри него?
Вы определяете функцию clickOutsideHandler
как useState
, вам следует просто сделать ее реальной функцией, поскольку она не использует никакой логики состояния.
Я бы изменил это на что-то вроде этого (без ts)
const clickOutsideHandler = (evt) => {
if (!isOpen || popoverRef.current.contains(evt.target)) { return; } // Clicked on one of the own children
close();
};
Тем не менее, вместо создания функций open
и close
вы можете использовать функцию стрелки в обработчике setState, чтобы получить текущее значение, а затем переключить его, например:
const toggleOpenState = () => setIsOpen(cur => !cur);
Если вы определите это так, document.removeEventListener('click', clickOutsideHandler);
не будет работать, поскольку clickOutsideHandler
будет другим экземпляром функции при повторной визуализации компонента.
Это должно работать нормально, не забудьте добавить минимальный воспроизводимый пример к исходному вопросу?
Я добавил компонент «Пример», который открывает всплывающее окно при нажатии кнопки. Если я использую ваш пример, clickOutsideHandler работает нормально в первый раз, но он не будет удален с помощью removeEventListener
, поскольку он становится экземпляром функции, отличным от того, который использовался с addEventListener
при повторной визуализации компонента. поэтому, когда вы нажимаете кнопку во второй раз, она открывается нормально, но также сразу же закрывается, поскольку старый прослушиватель все еще там.
Это проблема Javascript Закрытие.
Вы определяете функцию, которая закрывается по значению состояния isOpen
из начального цикла рендеринга при объявлении состояния clickOutsideHandler
.
const [isOpen, setIsOpen] = useState(false);
const [clickOutsideHandler] = useState(() =>
(evt: MouseEvent): void => {
console.info('!isOpen: ' + isOpen);
if (!isOpen || popoverRef.current.contains(evt.target as Node)) {
return;
} // Clicked on one of the own children
close();
}
);
Состояние clickOutsideHandler
никогда не обновляется для повторного включения текущего значения состояния isOpen
, поэтому оно всегда имеет начальное значение состояния false isOpen
.
Вместо использования состояния React для хранения обратного вызова чаще используется хук useCallback
(т. е. useState
+ useEffect
для обновления обратного вызова -> useCallback
) с соответствующими зависимостями для правильного закрытия значений и предоставления стабильной ссылки на обратный вызов.
Пример:
Обратите внимание, что clickOutsideHandler
также необходимо включить в зависимости useEffect
-хука, чтобы эффект был правильно настроен.
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]);
Приведенное выше решение не удаляет ранее добавленные прослушиватели событий. Эффект должен возвращать функцию очистки для правильного удаления ранее добавленных прослушивателей событий. Переместите объявление clickOutsideHandler
в обратный вызов useEffect
, чтобы оно стало внутренней ссылкой для функции очистки.
Добавляйте прослушиватель событий только тогда, когда isOpen
правдиво, и удаляйте прослушиватель только в функции очистки.
Вы также можете удалить проверку isOpen
clickOutsideHandler
, поскольку ее можно вызвать только в том случае, если isOpen
было истинно и был добавлен прослушиватель событий.
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]);
Спасибо, это тот ответ, который я искал. В большинстве онлайн-примеров возврат используется в сочетании с версией монтирования компонента (с помощью []
), чтобы что-то сделать, когда компонент размонтируется. Мне не совсем понятно, что возврат, например, [isOpen]
вызывается всякий раз, когда isOpen
собирается измениться на новое значение.
Не могли бы вы добавить минимально воспроизводимый пример ?