무엇
createPortal은 자식을 화면상 다른 위치의 DOM 노드에 그리게 해 주는 API다. 그리는 위치만 옮겨질 뿐, React 트리에서는 여전히 부모의 자식으로 남는다.
인자는 세 개다. 그릴 children, 그 자식을 넣을 이미 존재하는 domNode, 그리고 필요할 때 쓰는 key다.
import { createPortal } from 'react-dom'
createPortal(children, document.getElementById('modal-root')!)
돌려주는 값은 JSX에 넣거나 컴포넌트에서 반환할 수 있는 React 노드다. React가 그 children을 지정한 domNode 안에 넣는다.
무슨 일이 일어나나
포털은 DOM상 물리적 위치만 바꾼다. 그 외에는 포털에 넣은 JSX가 이 컴포넌트의 평범한 자식처럼 동작한다. 위쪽 Context를 그대로 읽고, state와 prop도 평소대로 흐른다.
이벤트도 마찬가지다. 이벤트는 DOM 트리가 아니라 React 트리를 따라 버블링된다. 포털 내용이 document.body에 붙어 있어도, 그것을 감싼 <div onClick>의 핸들러는 포털 안 클릭에도 반응한다. 눈에 보이는 위치와 이벤트가 타고 올라가는 경로가 다른 셈이다.
domNode는 이미 존재해야 한다. 업데이트 중에 다른 DOM 노드를 넘기면 포털 내용이 처음부터 다시 만들어진다.
사용법
부모의 CSS overflow나 z-index에 갇히면 안 되는 UI를 트리 밖으로 내보낸다. 대표적으로 모달이다.
import { useState } from 'react'
import { createPortal } from 'react-dom'
export function ModalExample() {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>모달 열기</button>
{open &&
createPortal(
<div className="modal">
<div>나는 모달이다</div>
<button onClick={() => setOpen(false)}>닫기</button>
</div>,
document.body,
)}
</>
)
}
createPortal의 반환값을 그대로 JSX에 섞어 넣는다. 내용은 document.body에 그려지지만, 이 컴포넌트의 state와 핸들러를 그대로 쓴다.
실무 예시
모달, 툴팁, 드롭다운처럼 부모의 overflow: hidden이나 스택 순서를 벗어나야 하는 오버레이가 대표적이다. 포털이 그것을 트리 안에 논리적으로 묶어 둔 채 화면상으로만 꺼내 준다.
import { createPortal } from 'react-dom'
export function Tooltip({ text, open }: { text: string; open: boolean }) {
if (!open) return null
// 부모의 overflow에 잘리지 않도록 body에 그린다
return createPortal(
<div className="tooltip" role="tooltip">
{text}
</div>,
document.body,
)
}
왜 중요한가
모달, 툴팁, 드롭다운처럼 부모의 CSS 제약을 벗어나야 하는 UI에서 사실상 표준 도구다. React 트리와 DOM 위치를 분리해 주는 몇 안 되는 API라, 오버레이 계열을 다루다 보면 반드시 만나게 된다. 직접 부르지 않더라도 UI 라이브러리 내부에서 널리 쓰이는 기본기다.
Reference