[Event] 모달 dimmed 영역 클릭 시 안 닫히는 문제 해결

Gyuhan Park·2024년 8월 5일
1

Trouble Shooting

목록 보기
8/10

💭 문제 상황

  • 모달 dimmed 영역 클릭 시 모달을 닫는 콜백함수 실행하려고 함
  • 모달이 안열림 !!!
    • 콘솔을 찍어보니 모달이 열리자마자 닫힘
    • isOpen이 true가 되자마자 false가 됨
    • mousedown, mouseup으로 하면 잘 됨
    • mousedown → mouseup → click 으로 이벤트가 발생한 시점만 다른데 왜 click만 안되는가?

🔍 현재 구현 방식(의도)

  • button 클릭했을 때 이벤트 핸들러가 동작하여 setIsOpen(true)를 수행해 모달을 연다.
  • useEffect로 클릭 이벤트 리스너를 등록하여 클릭이 발생했을 때 dimmed 영역을 클릭했을 때 handleBackdropClick 함수를 수행한다.
  • 모달 내부가 아닌 곳을 클릭했을 때 onClose() 가 실행되어 모달이 닫힌다.
import { useEffect } from 'react';

const useModalBackdropClickClose = (
  isOpen: boolean,
  modalRef: React.MutableRefObject<HTMLElement | null>,
  onClose: () => void,
) => {
  useEffect(() => {
    const handleBackdropClick = (event: MouseEvent) => {
      if (isOpen && modalRef.current && !modalRef.current.contains(event.target as Node)) {
        onClose();
      }
    };

    document.addEventListener('click', handleBackdropClick);

    return () => {
      document.removeEventListener('click', handleBackdropClick);
    };
  }, [isOpen]);
};

export default useModalBackdropClickClose;

❌ 문제점

버튼을 클릭하여 버블링된 이벤트 객체가 리스너 콜백함수에서 처리될 때는 isOpen이 true인 상태다.

  • button 클릭했을 때 클릭 이벤트 발생
  • button에서 발생한 클릭 이벤트가 버블링되어 document로 올라간다.
  • useEffect에서 document에 등록한 클릭 이벤트 리스너에서 클릭 이벤트를 감지하여, 등록한 콜백 함수를 수행한다.
    • document.addEventListener(’click’, listener)
  • 등록한 콜백 함수를 수행할 때는 isOpen이 true이고 modal 밖을 클릭했다고 인식하기 때문에 바로 onClose가 실행된다.
  • → React 컴포넌트가 브라우저 렌더링이 끝난 후 이벤트 리스너 콜백함수가 동작한다.

✅ 해결 방법

결론적으로는 button에서 이벤트가 발생한 이후 다음 사이클에 이벤트 리스너가 이벤트를 수신해야 정상적으로 동작한다는 것이다. 이를 수행하기 위해 현재 상황에서 3가지 방법으로 개선할 수 있다.

1. useClickModalDimmed을 그대로 사용하고 수신할 이벤트 타입을 mousedown 또는 mouseup 으로 변경한다.

mousedown 또는 mouseup은 click 이전에 발생하기 때문에, 버튼의 click 이벤트가 발생한 시점에는 캐치하지 못하고, 발생 이후에 이벤트를 수신한다.

document.addEventListener('mousedown', handleBackdropClick);

2. 캡처링 단계에서 이벤트를 수신한다.

버튼에서 이벤트가 발생했으므로, 버튼의 클릭 이벤트가 발생한 시점에는 캡쳐링에 isOpen이 false기 때문에 handleBackdropClick이 실행되더라도 onClose는 실행되지 않아 정상 동작한다.

document.addEventListener('click', handleBackdropClick, { capture: true });

3. document에 이벤트리스너를 등록하지 않고 dimmed에 onClose, 모달 onClick 이벤트 핸들러에서 stopPropagation() 을 호출한다.

모달이 띄워져있을 때 모달을 클릭해도 버블링으로 이벤트가 전파되어 클릭 이벤트가 감싸고 있는 dimmed 영역에도 전달되고 handleClickDimmed 가 실행된다.

이를 방지하기 위해 모달 클릭 이벤트 핸들러에 stopPropagation을 호출하여 이벤트 전파를 막는다.

event.stopPropagation() 호출 ❌
event.stopPropagation() 호출 ✅
import { useEffect, useRef, useState } from 'react';

function App() {
  const [isOpen, setIsOpen] = useState(false);
  const modalRef = useRef(null);

  const handleClickOpen = () => {
    setIsOpen(true);
  };

  const handleClickDimmed = () => {
    setIsOpen(false);
  };
  
  const handleClickModal = (e: React.MouseEvent<HTMLDivElement>) => {
    e.stopPropagation();
    console.log('modal clicked!');
  };

  return (
    <>
      <button onClick={handleClickOpen}>오픈!</button>

      {isOpen && (
        <div
          style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0, 0, 0, 0.6)' }}
          onClick={handleClickDimmed}
        >
          <div
            onClick={handleClickModal}
            ref={modalRef}
            style={{
              position: 'fixed',
              backgroundColor: 'white',
              width: 200,
              height: 200,
              top: '50%',
              left: '50%',
            }}
          ></div>
        </div>
      )}
    </>
  );
}

export default App;

📘 흐름을 이해해보자

어떤 문제를 풀려고 하는지 한 문장으로 적는다. (문제 상황)

버튼을 클릭하여 모달을 열었는데 모달이 순간적으로 열렸다가 닫힌다.

올바르게 동작한다면 어떤 일이, 어떤 순서로 벌어져야 하는지 적는다. (올바른 상황)

버튼을 클릭하여 모달을 열고, dimmed 영역을 클릭하면 모달이 닫힌다.

최소 재현 환경을 구축하면서 관찰하고, 정상 환경과는 어떻게 다른지 비교한다.

문제가 있었던 훅을 그대로 가져오고, 모달과 dimmed 영역을 유사하게 구현한다.
기능적으로 똑같이 동작하는 환경에서 로그를 확인하여 어떤 순서로 동작하는지 확인한다.

useClickModalDimmed.ts

import { useEffect } from 'react';

const useClickModalDimmed = (
  isOpen: boolean,
  modalRef: React.MutableRefObject<HTMLElement | null>,
  onClose: () => void,
) => {
  useEffect(() => {
    console.log('useEffect isOpen:', isOpen);
    const handleBackdropClick = (event: MouseEvent) => {
      console.log('handleBackdropClick isOpen:', isOpen);

      if (isOpen && modalRef.current && !modalRef.current.contains(event.target as Node)) {
        onClose();
      }
    };

    document.addEventListener('click', handleBackdropClick);

    return () => {
      console.log('useClickModalDimmed (rerender)');
      document.removeEventListener('click', handleBackdropClick);
    };
  }, [isOpen]);
};

export default useClickModalDimmed;

App.ts (모달)

import { useEffect, useRef, useState } from 'react';
import './App.css';
import useClickModalDimmed from './useClickModalDimmed';

function App() {
  const [isOpen, setIsOpen] = useState(false);
  const modalRef = useRef(null);

  const handleClickOpen = () => {
    console.log('button clicked!');
    setIsOpen(true);
  };

  const handleClickDimmed = () => {
    setIsOpen(false);
  };

  useClickModalDimmed(isOpen, modalRef, handleClickDimmed);

  useEffect(() => {
    console.log('App useEffect isOpen:', isOpen);
    return () => console.log('App useEffect (rerender)');
  });

  return (
    <>
      <button onClick={handleClickOpen}>오픈!</button>

      {isOpen && (
        <div style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0, 0, 0, 0.6)' }}>
          <div
            ref={modalRef}
            style={{
              position: 'fixed',
              backgroundColor: 'white',
              width: 200,
              height: 200,
              top: '50%',
              left: '50%',
            }}
          ></div>
        </div>
      )}
    </>
  );
}

export default App;

[ 버튼을 클릭한 후 출력되는 콘솔을 관찰하며 상황을 이해해보자. ]

  1. 버튼 클릭하여 클릭 이벤트가 발생하고, setState로 리렌더링을 트리거한다.
  2. App의 useEffect보다 위에 있는 useClickModalDimmed 클린업 함수 먼저 실행된다.
  3. App 클린업 함수 실행된다.
  4. isOpen이 true인 스냅샷으로 useClickModalDimmed와 App이 렌더링된다.
  5. isOpen이 true인 상태로 브라우저 렌더링이 끝난 후, document에서 수신한 클릭 이벤트의 콜백함수인 handleBackdropClick 실행한다.
    • App useEffect가 실행된 후 handleBackdropClick 함수가 실행된 것을 보고, 브라우저 렌더링 후 실행되었음을 확인할 수 있다
  6. handleBackdropClick를 수행하는 시점에는 isOpen이 true이며, 모달 밖을 클릭한 것으로 인식하여 onClose 함수가 동작한다.
  7. setState로 리렌더링을 트리거하며 2, 3번의 작업이 이뤄지고 isOpen이 false인 스냅샷으로 useClickModalDimmed와 App이 렌더링된다.
  8. 따라서 버튼을 누름과 동시에 모달이 켜졌다 꺼지는 현상이 발생하였다.

profile
단단한 프론트엔드 개발자가 되고 싶은

0개의 댓글