Добавление и удаление прослушивателей в компоненте React

Я пытаюсь добавить и удалить прослушиватели событий вдовы при открытии и закрытии компонента.

Проблема в том, что 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>
            }
        </>
    );
});

Вопрос в следующем: как я могу заставить обработчик сохраняться, сохраняя при этом доступ к правильному состоянию внутри него?

Не могли бы вы добавить минимально воспроизводимый пример ?

0stone0 15.08.2024 13:25
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Навигация по приложениям React: Исчерпывающее руководство по React Router
Навигация по приложениям React: Исчерпывающее руководство по React Router
React Router стала незаменимой библиотекой для создания одностраничных приложений с навигацией в React. В этой статье блога мы подробно рассмотрим...
Массив зависимостей в React
Массив зависимостей в React
Все о массиве Dependency и его связи с useEffect.
1
1
50
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Вы определяете функцию 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 будет другим экземпляром функции при повторной визуализации компонента.

Arno van Oordt 15.08.2024 13:50

Это должно работать нормально, не забудьте добавить минимальный воспроизводимый пример к исходному вопросу?

0stone0 15.08.2024 13:56

Я добавил компонент «Пример», который открывает всплывающее окно при нажатии кнопки. Если я использую ваш пример, clickOutsideHandler работает нормально в первый раз, но он не будет удален с помощью removeEventListener, поскольку он становится экземпляром функции, отличным от того, который использовался с addEventListener при повторной визуализации компонента. поэтому, когда вы нажимаете кнопку во второй раз, она открывается нормально, но также сразу же закрывается, поскольку старый прослушиватель все еще там.

Arno van Oordt 15.08.2024 14:56
Ответ принят как подходящий

Проблема

Это проблема 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 правдиво, и удаляйте прослушиватель только в функции очистки.

Вы также можете удалить проверку isOpenclickOutsideHandler, поскольку ее можно вызвать только в том случае, если 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 собирается измениться на новое значение.

Arno van Oordt 16.08.2024 09:33

Другие вопросы по теме