React에서 createPortal을 활용해 팝오버 메뉴(Popover Menu)를 구현할 때는, 스타일과 이벤트 처리에 대한 고려가 필요합니다. 이번 포스트에서는 기존 CSS hover를 사용하는 코드에서, React 상태(useState)로 이벤트를 관리하는 코드로의 전환 과정을 소개하고, createPortal 사용 시 발생하는 이벤트 버블링 문제 해결 방법을 다룹니다.
기존 서비스에서 Popover Menu는 특정 요소에 종속되지 않고 자유롭게 위치하도록, createPortal을 활용해 body에 렌더링했습니다. createPortal을 사용하면 overflow: hidden과 같은 부모 요소의 스타일 영향을 받지 않아, Popover Menu가 자유롭게 표시될 수 있습니다.
import { PropsWithChildren, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import { PopoverProvider } from '@contexts/PopoverContext';
import checkElementPosition from '@utils/checkElementPosition';
import S from './style';
interface PopoverProps extends PropsWithChildren {
isOpen: boolean;
onClose: () => void;
anchorEl: HTMLElement | null;
}
export default function Popover({ isOpen, onClose, anchorEl, children }: PopoverProps) {
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node) && event.target !== anchorEl) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mouseup', handleClickOutside);
document.addEventListener('scroll', onClose, true);
}
return () => {
document.removeEventListener('mouseup', handleClickOutside);
document.removeEventListener('scroll', onClose, true);
};
}, [isOpen, onClose, anchorEl]);
useEffect(() => {
if (isOpen && popoverRef.current && anchorEl) {
const anchorRect = anchorEl.getBoundingClientRect();
const { isRight, isBottom } = checkElementPosition(anchorEl);
if (isBottom) {
popoverRef.current.style.bottom = `${window.innerHeight - anchorRect.bottom + window.screenY}px`;
} else {
popoverRef.current.style.top = `${anchorRect.bottom + window.scrollY}px`;
}
if (isRight) {
popoverRef.current.style.right = `${window.innerWidth - anchorRect.right + window.scrollX}px`;
} else {
popoverRef.current.style.left = `${anchorRect.left + window.scrollX}px`;
}
}
}, [isOpen, anchorEl]);
if (!isOpen) return null;
return ReactDOM.createPortal(
<PopoverProvider onClose={onClose}>
<S.PopoverWrapper
ref={popoverRef}
role="dialog"
aria-modal="true"
onClick={(event: React.MouseEvent) => event.stopPropagation()}
>
{children}
</S.PopoverWrapper>
</PopoverProvider>,
document.body,
);
}
❗️ createPortal 사용의 문제점
Popover Menu가 body에 렌더링되면서, 기존 카드 요소의 hover 이벤트가 버블링되지 않습니다. createPortal로 렌더링된 Popover는 원래 위치한 DOM 트리의 계층 구조에서 분리되어 hover 이벤트가 예상대로 작동하지 않게 됩니다.
이 문제를 해결하기 위해 React의 상태 관리(useState)를 통해 hover 상태를 제어하도록 리팩토링했습니다. 기존 CSS에서 hover를 통해 직접 스타일을 적용하는 대신, React의 상태로 hover 여부를 판별하여 필요한 스타일을 동적으로 렌더링합니다.
const CardContainer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
min-width: 15rem;
padding: 1rem 1.6rem;
border-radius: 0.8rem;
user-select: none;
background-color: ${({ theme }) => theme.baseColors.grayscale[50]};
border: 1px solid ${({ theme }) => theme.baseColors.grayscale[400]};
transition: all 0.2s;
&:hover {
scale: 1.01;
transform: translateY(-0.1rem);
box-shadow: 0 0.2rem 0.6rem rgba(0, 0, 0, 0.1);
border: 1px solid ${({ theme }) => theme.baseColors.grayscale[500]};
cursor: pointer;
z-index: 9;
}
`;
이 코드에서는 hover 스타일이 CardContainer에 직접 적용되지만, createPortal을 사용해 Popover Menu를 렌더링하면 해당 카드 요소의 hover 상태를 인식하지 못합니다.
const CardContainer = styled.div<{ isHover: boolean }>`
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
min-width: 15rem;
padding: 1rem 1.6rem;
border-radius: 0.8rem;
user-select: none;
background-color: ${({ theme }) => theme.baseColors.grayscale[50]};
border: 1px solid ${({ theme }) => theme.baseColors.grayscale[400]};
transition: all 0.2s;
${({ theme, isHover }) =>
isHover &&
css`
scale: 1.01;
transform: translateY(-0.1rem);
box-shadow: 0 0.2rem 0.6rem rgba(0, 0, 0, 0.1);
border: 1px solid ${theme.baseColors.grayscale[500]};
cursor: pointer;
z-index: 9;
`}
`;
isHover라는 상태를 정의하고, onMouseEnter 및 onMouseLeave 이벤트 핸들러를 통해 hover 여부를 감지하여 상태 기반으로 스타일을 변경했습니다.
❓ 리액트는 왜 이런 독립적인 이벤트 구조를 가지고 있는걸까?
Virtual DOM의 장점과 이벤트
- 리액트의 독립적인 이벤트 시스템은 코드의 일관성과 유지보수를 용이하게 합니다. 리액트에서 모든 이벤트는 합성 이벤트로 감싸져 제공되기 때문에, 개발자는 브라우저에 따른 이벤트 처리 차이점을 걱정하지 않고 작성할 수 있습니다. 또한, 합성 이벤트는 모든 이벤트를 일관된 방식으로 취급하므로, 이벤트 처리가 더욱 직관적이며 메모리 누수 방지와 성능 최적화를 돕습니다. 이 시스템 덕분에 이벤트 위임이 가능한데, 이는 특정 DOM 노드마다 개별 이벤트 리스너를 할당하는 것이 아닌 최상위 DOM 요소에 이벤트 리스너 하나를 할당하여 효율성을 더욱 높일 수 있습니다.
- 리액트의 독립적인 이벤트 구조는 Virtual DOM과 밀접한 관련이 있습니다. Virtual DOM은 변경 사항을 메모리에 유지하며 실제 DOM에 접근하는 빈도를 줄여, 빠르고 효율적인 UI 업데이트가 가능하도록 돕는 중요한 메커니즘입니다. 하지만, DOM이 아닌 Virtual DOM에서 변경 사항을 감지하고 적용하려면 DOM 이벤트를 직접적으로 사용하기보다, Virtual DOM 내에서의 이벤트 관리가 필요합니다. 리액트의 합성 이벤트(Synthetic Event) 시스템은 이러한 구조를 가능하게 합니다. 합성 이벤트 시스템은 브라우저 간의 이벤트 불일치를 최소화하고, 다양한 이벤트를 통합하여 일관된 API로 제공함으로써 효율적인 이벤트 관리를 가능하게 합니다. 특히, 리액트가 하나의 이벤트 리스너로 다양한 이벤트를 관리할 수 있게 함으로써 메모리와 성능을 최적화합니다.
결론!
이러한 과정을 통해 리액트의 이벤트 구조는 단순히 독립적인 체계가 아니라, 효율성과 일관성, 그리고 성능을 위한 중요한 설계임을 이해하게 되었습니다. 특히, 합성 이벤트 시스템을 통해 Virtual DOM과의 상호작용을 매끄럽게 유지하고, 상태와 이벤트가 동기화되도록 설계함으로써, 리액트 컴포넌트는 더욱 직관적이고 유지보수하기 쉬운 구조로 관리될 수 있음을 느꼈습니다.
2편도 있어요.. ⤵️
'Front End > React' 카테고리의 다른 글
[Apollo Basic] Apollo Client를 이용하여 Supabase GraphQL 요청 보내기 (2) | 2024.11.27 |
---|---|
[Settings] GraphQL과 Supabase, Apollo Client 설정 (0) | 2024.11.26 |
[React] useLayoutEffect와 useEffect (0) | 2024.11.22 |
Tanstack Query의 useQuery 캐싱 매커니즘 분석하기 (0) | 2024.11.21 |
크루루 서비스의 PopOverMenu 컴포넌트 개선기(2), 자식 요소 컴포넌트 렌더러 만들기 (3) | 2024.11.16 |