포털은 컴포넌트의 일부를 DOM의 다른 위치, 보통 document.body에 렌더링하면서도 React 트리에서는 같은 자리에 둡니다. react-dom의 createPortal(children, domNode)로 만듭니다. 모달, 툴팁, 드롭다운 메뉴가 overflow: hidden이나 자기만의 쌓임 맥락을 가진 부모에서 벗어나는 방법이 포털입니다.
아래 상자는 밖으로 튀어나오는 것을 모두 잘라 냅니다. 두 팁을 모두 열어 보세요.
일반 팁은 점선 테두리에서 잘립니다. 포털 팁은 DOM 노드가 상자가 아니라 <body>의 자식이므로 전부 보입니다. 상자에서 overflow: 'hidden'을 지우면 일반 팁도 더 이상 잘리지 않습니다.
문법
import { createPortal } from 'react-dom';
createPortal(children, domNode, key?)
children은 요소, 프래그먼트, 컴포넌트 등 어떤 JSX든 됩니다.domNode는document.body나document.getElementById('modal-root')같은 기존 DOM 요소입니다. 포털이 렌더링될 때 존재해야 합니다.key는 선택 사항으로, 포털 목록을 렌더링할 때 씁니다.
createPortal은 다른 요소처럼 JSX에 넣는 것을 반환합니다. 부모 자신의 DOM 위치에는 아무것도 렌더링하지 않습니다.
document.body 안의 모달
모달이 대표적인 경우입니다. transform을 가진 카드 안에서 position: fixed 오버레이는 창이 아니라 그 카드를 기준으로 배치되고, 그다음 카드의 overflow: hidden이 그것을 잘라 냅니다. document.body에 렌더링하면 뷰포트 전체를 덮습니다.
모달을 연 다음 Escape를 누르거나 어두운 오버레이를 클릭해서 닫아 보세요. 열릴 때 포커스가 Close 버튼으로 이동합니다. 이제 createPortal 없이 오버레이를 반환해 보세요(호출과 document.body 인자를 지웁니다). transform이 카드를 fixed 요소의 포함 블록으로 만들기 때문에 오버레이가 카드 크기로 줄어듭니다.
이벤트는 React 트리를 따라 버블링됩니다
포털은 컴포넌트가 사는 곳이 아니라 DOM 노드가 사는 곳을 바꿉니다. React 이벤트는 React 트리를 따라 올라가므로, DOM에서는 버튼이 <body>의 자식이더라도 포털 안의 클릭은 그것을 렌더링한 컴포넌트의 onClick에 도달합니다.
네이티브 리스너는 다릅니다. addEventListener로 추가한 리스너는 DOM 트리를 따르므로 그 클릭을 절대 보지 못합니다.
"Inside the wrapper"를 클릭하면 React 핸들러와 네이티브 리스너가 모두 로그를 남깁니다. "In a portal"을 클릭하면 React 핸들러만 로그를 남깁니다. 포털 버튼은 화면에서 점선 래퍼 바깥에 있지만, React는 여전히 자식으로 취급합니다.
보통은 이것이 원하는 동작입니다. 메뉴 부모의 onClick이 포털 항목의 클릭을 보고, 컴포넌트 위의 컨텍스트 Provider도 포털 안에 적용됩니다. 하지만 "바깥을 클릭하면 닫기" 로직에서는 놀랄 수 있습니다. 메뉴를 닫는 래퍼의 onClick은 메뉴 포털 안의 클릭에도 발생하므로, 메뉴 안에서 전파를 멈추거나(e.stopPropagation(), 이벤트 페이지에서 다룹니다) 네이티브 document 리스너를 쓰고 대상이 포털의 DOM 노드 안에 있는지 확인하세요.
직접 만든 컨테이너로 포털 보내기
document.body가 가장 간단한 대상입니다. 어떤 앱은 모든 오버레이가 한곳과 하나의 쌓임 순서를 공유하도록 index.html에 전용 컨테이너를 추가합니다.
<body>
<div id="root"></div>
<div id="modal-root"></div>
</body>
createPortal(<Modal />, document.getElementById('modal-root'));
서버 렌더링(Next.js 등의 프레임워크)에서는 서버에 document가 없습니다. 이펙트가 true로 설정하는 mounted state 뒤에 두는 식으로, 컴포넌트가 마운트된 뒤에만 포털을 렌더링하세요.
포털을 트리거 옆에 배치해야 한다면 먼저 트리거를 측정하세요. 첫 번째 예제는 클릭 핸들러에서 측정합니다. 깜빡이면 안 되는 툴팁이라면 브라우저가 그리기 전에 실행되는 useLayoutEffect에서 측정하세요.
접근성 있는 모달
마크업을 document.body로 옮기는 것만으로는 키보드와 스크린 리더 사용자에게 아무 도움이 되지 않습니다. 모달에는 다음도 필요합니다.
role="dialog"와aria-modal="true", 그리고 제목을 가리키는aria-labelledby.- 열릴 때 대화상자 안으로 포커스 이동(예제는 Close에 포커스합니다), 닫힐 때 그것을 연 버튼으로 포커스 복귀.
- Escape로 닫기.
- 열려 있는 동안 포커스를 안에 가두어 Tab이 뒤쪽 페이지로 가지 않게 하기. 모달이 열려 있는 동안 앱 루트에
inert속성을 설정하면 그곳의 포커스와 클릭이 막힙니다.
네이티브 <dialog> 요소가 이 대부분을 대신 처리해 줍니다. dialogRef.current.showModal()로 열면 브라우저의 최상위 계층에 모든 z-index 위로 그려지고, 페이지의 나머지를 inert로 만들며, Escape로 닫힙니다. 포털이 필요 없으므로 간단한 확인 대화상자에 좋은 기본 선택이며, 툴팁, 메뉴, 직접 만든 오버레이에는 여전히 포털이 맞는 도구입니다.
z-index만으로 충분하지 않은 이유
개발자들은 큰 z-index로도 메뉴를 페이지의 나머지 위에 올리지 못한 뒤에 포털을 찾곤 합니다. 원인은 쌓임 맥락입니다. position과 z-index를 가진 요소, 1보다 작은 opacity, transform, filter, isolation: isolate는 새 쌓임 맥락을 시작하며, 그 자식들의 z-index 값은 그 안에서만 서로 경쟁합니다. z-index: 1인 카드 안의 z-index: 9999 자식은 여전히 z-index: 2인 형제 카드 아래에 있습니다.
포털은 요소를 그런 모든 조상 밖으로 옮깁니다. <body>의 자식으로서 그 z-index는 페이지의 최상위 요소들과 비교되므로, 오버레이에는 1000 정도의 적당한 값이면 충분합니다.
자주 하는 실수
아직 존재하지 않는 노드로 포털 보내기. 요소가 없으면 document.getElementById('modal-root')는 null을 반환하고, createPortal은 "Target container is not a DOM element"를 던집니다. HTML을 확인하거나 document.body로 포털을 보내세요.
렌더링 중에 대상 노드 만들기. 컴포넌트 본문에 document.createElement('div')를 쓰면 렌더링마다 새 노드가 생깁니다. 이펙트에서 한 번 만들거나 고정된 컨테이너를 쓰세요.
트리거를 따라가지 않는 팝업. getBoundingClientRect()로 배치한 툴팁은 페이지가 스크롤되거나 크기가 바뀌면 트리거를 따라가지 않습니다. scroll과 resize 때 다시 계산하거나(이펙트의 클린업에서 그 리스너들을 제거하세요), 페이지가 스크롤되면 툴팁을 닫으세요.
자주 묻는 질문
React에서 포털이란 무엇인가요?
부모 컴포넌트의 DOM 요소 바깥에 있는 DOM 노드에 자식을 렌더링하는 방법입니다. react-dom의 createPortal(children, domNode)로 만듭니다. 자식은 React 트리에서 자기 자리를 유지하므로 props, state, 컨텍스트가 평소처럼 동작합니다.
포털은 언제 써야 하나요?
무언가가 컨테이너 위나 바깥에 나타나야 할 때입니다. 모달, 툴팁, 드롭다운 메뉴, 토스트가 그렇습니다. 그렇지 않으면 overflow: hidden, transform, 또는 자기만의 쌓임 맥락을 가진 부모가 그것을 잘라 내거나 가립니다.
포털 밖으로 이벤트가 버블링되나요?
네, React 트리를 따라서 버블링됩니다. DOM 노드가 document.body에 있더라도 포털 안의 클릭은 React 부모의 onClick 핸들러에 도달합니다. addEventListener로 추가한 네이티브 리스너는 대신 DOM 트리를 따릅니다.
포털 안에서 컨텍스트가 동작하나요?
네. 컨텍스트도 이벤트처럼 React 트리를 따릅니다. document.body에 렌더링한 모달도 그것을 만든 컴포넌트 위의 Provider에서 테마나 사용자를 읽습니다.
모달에 포털이 꼭 필요한가요?
항상 그렇지는 않습니다. showModal()로 연 네이티브 <dialog> 요소는 브라우저의 최상위 계층에 그려져 모든 z-index 위에 있고, 포커스 처리도 내장되어 있습니다. 직접 만든 모달과 툴팁, 메뉴에는 보통 포털을 선택합니다.