이번 편에서는 React Portals를 활용하는 개인적인 방법을 소개하겠습니다.

React Portals를 어떻게 활용할 수 있는지 참고자료로써 쓰이기를 희망합니다.

소스코드

import React, { useEffect, useMemo } from 'react'
import { createPortal } from 'react-dom'

const Portal = ({ id, children }) => {
  const [parentElement, cleanupParentElement] = useMemo(() => createParentElement(id), [])

  useEffect(() => () => cleanupParentElement(), [])

  return createPortal(children, parentElement)
}

const createParentElement = id => {
  const rootElement = getRootElement(id)
  const parentElement = document.createElement('div')

  rootElement.appendChild(parentElement)

  const cleanupParentElement = () => {
    rootElement.removeChild(parentElement)
  }

  return [parentElement, cleanupParentElement]
}

const getRootElement = id => {
  const rootElement = document.getElementById(id)
  if (rootElement !== null) {
    return rootElement
  }

  const nextRootElement = document.createElement('div')
  nextRootElement.setAttribute('id', id)
  document.body.appendChild(nextRootElement)

  return nextRootElement
}

크게 3단계 구성입니다.

  • getRootElement: 해당하는 id 엘리먼트를 불러오며, 없으면 body 태그 안에 추가한다.
  • createParentElement: 위에서 만든 Root 엘리먼트 안에 새로운 div 엘리먼트를 추가한다.
  • createPortal: 위에서 만든 Parent 엘리먼트에 Portal을 생성한다.

추가적으로 메모리 누수를 막기 위하여 Portal을 종료하면 해당 Parent 엘리먼트를 제거하는 로직이 있습니다.

같은 id이면 그 엘리먼트 안에 새로운 Parent 엘리먼트를 추가하는 원리로써 아래의 장점을 가집니다.

  • createPortal 함수를 그대로 사용하는 것 보다 편리하다.
  • 경고창, 결제창, 설정창 등 여러 대화상자 컴포넌트의 논리적 우선순위를 정리하기 용이하다.
  • /public/index.html 를 수정할 필요가 없다

활용 방법

const App = () => (
  <>
    <div>App</div>
    <Dialog>
      <User />
    </Dialog>
  </>
)

const Dialog = ({ children }) => (
  <Portal id='dialog'>{children}</Portal>
)

const User = () => (
  <div>User</div>
)

심화

위에서 작성한 Portal 컴포넌트를 응용하면 다양한 기능을 유연하게 추가할 수 있습니다.

const EscapeablePortal = ({ id, isOpen, close, children }) => {
  const closePortalToClickEscapeKey = () => {
    const closePortal = event => {
      if (event.key === 'Escape') {
        close()
      }
    }

    document.addEventListener('keydown', closePortal)

    return () => {
      document.addEventListener('keydown', closePortal)
    }
  }
  useEffect(closePortalToClickEscapeKey, [close])

  if (!isOpen) {
    return null
  }

  return (
    <Portal id={id}>{children}</Portal>
  )
}

키보드의 ESC 키를 클릭하면 Portal을 종료하는 컴포넌트입니다.