- 모달 dimmed 영역 클릭 시 모달을 닫는 콜백함수 실행하려고 함
- 모달이 안열림 !!!
- 콘솔을 찍어보니 모달이 열리자마자 닫힘
- isOpen이 true가 되자마자 false가 됨
- mousedown, mouseup으로 하면 잘 됨
- mousedown → mouseup → click 으로 이벤트가 발생한 시점만 다른데 왜 click만 안되는가?
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;
[ 버튼을 클릭한 후 출력되는 콘솔을 관찰하며 상황을 이해해보자. ]
- 버튼 클릭하여 클릭 이벤트가 발생하고, setState로 리렌더링을 트리거한다.
- App의 useEffect보다 위에 있는 useClickModalDimmed 클린업 함수 먼저 실행된다.
- App 클린업 함수 실행된다.
- isOpen이 true인 스냅샷으로 useClickModalDimmed와 App이 렌더링된다.
- isOpen이 true인 상태로 브라우저 렌더링이 끝난 후, document에서 수신한 클릭 이벤트의 콜백함수인 handleBackdropClick 실행한다.
- App useEffect가 실행된 후 handleBackdropClick 함수가 실행된 것을 보고, 브라우저 렌더링 후 실행되었음을 확인할 수 있다
- handleBackdropClick를 수행하는 시점에는 isOpen이 true이며, 모달 밖을 클릭한 것으로 인식하여 onClose 함수가 동작한다.
- setState로 리렌더링을 트리거하며 2, 3번의 작업이 이뤄지고 isOpen이 false인 스냅샷으로 useClickModalDimmed와 App이 렌더링된다.
- 따라서 버튼을 누름과 동시에 모달이 켜졌다 꺼지는 현상이 발생하였다.