[React] ReactDOM.createPortal()

박기영·2023년 1월 9일
8

React

목록 보기
21/32

클론 코딩을 하며 모달(Modal)을 구현하는데 ReactDOM.createPortal()을 사용하여 public/index.html에 컴포넌트를 렌더링하는 방법에 대해 알게되었다.
지금까지 public/index.html를 건드리는 것은 상상도 안 했고,
모달을 만든 적은 있지만 전혀 다른 방법이었기 때문에 기록해 놓고자 한다.
아무래도 이 방법이 더 보편적인 방법인 것으로 보인다.

어떤 기능을 하는걸까?

React 공식 문서에 적혀있는 해당 기능에 대한 설명은 아래와 같다.

Portals provide a first-class way to render children into a DOM node that exists outside the DOM hierarchy of the parent component.
- React 공식 docs -

Portal은 부모 컴포넌트의 DOM 계층 구조 바깥에 있는 DOM 노드로 자식을 렌더링하는 최고의 방법을 제공합니다.
- React 공식 docs -

필자는 이 설명을 다음과 같이 이해했다.

React 프로젝트에서는 구현된 것을 public/index.html<div id="root">에서 렌더링한다.
그런데 ReactDOM.createPortal()을 사용하면 <div id="root">가 아닌 다른 곳에서 이를 렌더링 할 수 있게 해준다.
분명히 컴포넌트 구현은 src/App.js 하위에서 되었음에도 말이다.(일반적인 경우)

// src/index.js

import React from 'react';
import ReactDOM from 'react-dom';

import './index.css';
import App from './App';

// App 컴포넌트를 public/index.html에서 id가 root인 태그에 렌더링한다.
// App 컴포넌트 하위에 다양한 컴포넌트들이 들어가 있다.
ReactDOM.render(<App />, document.getElementById('root'));

문법

ReactDOM.createPortal(child, container)

첫 번째 인자 child는 렌더링할 수 있는 React 자식이다.
쉽게 말해, 컴포넌트를 의미한다.

두 번째 인자 container는 DOM 엘리먼트이다.
쉽게 말해, child에 입력된 컴포넌트가 렌더링 될 public/index.html 내 태그를 의미한다.

사용법

render 메서드로 엘리먼트를 반환하게 되면, 해당 엘리먼트는 부모 노드에서 가장 가까운 자식으로 DOM에 마운트된다.

// React 공식 docs 발췌

render() {
  // React는 새로운 div를 마운트하고 그 안에 자식을 렌더링합니다.
  return (
    <div>
      {this.props.children}
    </div>
  );
}

그런데 DOM의 다른 위치에 자식을 삽입하는 것이 유용할 때가 있다.
예를들어, modal, dialogs, hovercards, tooltips 등을 구현할 떄가 있겠다.
혹은, 부모 컴포넌트에 overflow: hidden, z-index 스타일이 있는 경우가 그렇다.

// React 공식 docs 발췌

render() {
  // React는 새로운 div를 생성하지 *않고* `domNode` 안에 자식을 렌더링합니다.
  // `domNode`는 DOM 노드라면 어떠한 것이든 유효하고, 그것은 DOM 내부의 어디에 있든지 상관없습니다.
  return ReactDOM.createPortal(
    this.props.children,
    domNode
  );
}

적용 예시

실제 필자의 적용 사례는 다음과 같다.

<!-- public/index.html -->

<html lang="en">
  <!-- 생략 -->
  
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="modal-hook"></div>
    <div id="root"></div>
  </body>
</html>
// src/shared/components/UIElements/Modal.jsx

import React from "react";
import ReactDOM from "react-dom";

function ModalOverlay(props) {
  const content = (
    <div className={`modal ${props.className}`} style={props.style}>
		// 모달 구현부 생략
    </div>
  );
  
  return ReactDOM.createPortal(content, document.getElementById("modal-hook"));
}

CRA 프로젝트에서는 root에 렌더링을 하는데,
root 바깥에 modal-hook을 생성해놓고, 그 곳에 모달을 렌더링한다.
모달 컴포넌트는 App 컴포넌트 하위에서 구현했지만, React.createPortal()이 이걸 가능하게 한다.

이를 개발자 도구를 통해 살펴보면,
모달 렌더링 전에는 아래와 같고

참고 이미지

모달 렌더링 후에는 아래와 같다.

참고 이미지

App 컴포넌트 하위에서 작성되었음에도, root 태그가 아닌
전혀 다른 곳인 modal-hook 태그에서 렌더링이 되는 것을 확인 할 수 있다!

그렇다면, 전혀 다른 곳에 렌더링 된거니까...
상위 컴포넌트에서 작성된 것들이나 전역 상태 등에 접근하는 것이 불가능할까?
그렇지 않다.

렌더링 발생 태그만 다를 뿐이다!

렌더링이 다른 곳에 진행되었더라도, 여전히 일반적인 React 자식처럼 동작한다.
따라서, context와 같은 기능은 완전히 동일하게 동작한다.

즉, ReactDOM.createPortal()로 다른 곳에 렌더링 했다해서
다른 컴포넌트들은 활용 가능했던 것에 접근하지 못하는 일은 없다는 것이다!

이는 DOM 트리에서의 위치에 상관없이 해당 컴포넌트는 여전히 React 트리에 존재하기 때문이다.

참고 이미지

React 개발자 도구를 통해 확인해보니
ModalApp 컴포넌트의 하위 컴포넌트로 들어가 있는 것을 볼 수 있다.

이벤트 버블링

공식 문서에서는 이를 이벤트 버블링과 관련지어 설명해준다.
이벤트 버블링 또한 동일하게 적용된다는 것을 말하고자 하는 것이다.

ReactDOM.createPortal()이 적용된 컴포넌트에서 발생한 이벤트는
React 트리에 포함된 상위 컴포넌트로 전파된다.
비록, DOM 트리에서는 상위 요소가 아니더라도 말이다.

다음 예시를 보자. 설명을 위해 공식 문서 예시를 그대로 가져왔다.

<html>
  <body>
    <div id="app-root"></div>
    <div id="modal-root"></div>
  </body>
</html>

app-rootmodal-root는 형제 관계이다.

// 여기 이 두 컨테이너는 DOM에서 형제 관계입니다.
const appRoot = document.getElementById('app-root');
const modalRoot = document.getElementById('modal-root');

class Modal extends React.Component {
  constructor(props) {
    super(props);
    this.el = document.createElement('div');
  }

  componentDidMount() {
    // Portal 엘리먼트는 Modal의 자식이 마운트된 후 DOM 트리에 삽입됩니다.
    // 요컨대, 자식은 어디에도 연결되지 않은 DOM 노드로 마운트됩니다.
    // 자식 컴포넌트가 마운트될 때 그것을 즉시 DOM 트리에 연결해야만 한다면,
    // 예를 들어, DOM 노드를 계산한다든지 자식 노드에서 'autoFocus'를 사용한다든지 하는 경우에,
    // Modal에 state를 추가하고 Modal이 DOM 트리에 삽입되어 있을 때만 자식을 렌더링해주세요.
    modalRoot.appendChild(this.el);
  }

  componentWillUnmount() {
    modalRoot.removeChild(this.el);
  }

  render() {
    return ReactDOM.createPortal(
      this.props.children,
      this.el
    );
  }
}
class Parent extends React.Component {
  constructor(props) {
    super(props);
    this.state = {clicks: 0};
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    // 이것은 Child에 있는 버튼이 클릭 되었을 때 발생하고 Parent의 state를 갱신합니다.
    // 비록 버튼이 DOM 상에서 직계 자식이 아니라고 하더라도 말입니다.
    this.setState(state => ({
      clicks: state.clicks + 1
    }));
  }
  render() {
    return (
      <div onClick={this.handleClick}>
        <p>Number of clicks: {this.state.clicks}</p>
        <p>
          Open up the browser DevTools
          to observe that the button
          is not a child of the div
          with the onClick handler.
        </p>
        <Modal>
          <Child />
        </Modal>
      </div>
    );
  }
}

function Child() {
  // 이 버튼에서의 클릭 이벤트는 부모로 버블링됩니다.
  // 왜냐하면 'onClick' 속성이 정의되지 않았기 때문입니다.
  return (
    <div className="modal">
      <button>Click</button>
    </div>
  );
}
const root = ReactDOM.createRoot(appRoot);
root.render(<Parent />);

Modal 컴포넌트는 ReactDOM.createPortal()을 통해 modal-root에 렌더링되고,
Parent 컴포넌트는 app-root에 렌더링된다.

이 상황에서,
Modal 컴포넌트에서 버블링된 이벤트를 Parent 컴포넌트에서 포착할 수 있다.
전혀 다른 형제 태그에서 발생한 이벤트인데도 말이다.

이를 공식 문서에서는 다음과 같이 설명한다.
app-root 안에 있는 Parent 컴포넌트는 형제 노드인 modal-root 안의 Modal 컴포넌트에서 전파된 이벤트가 포착되지 않았을 경우 그것을 포착할 수 있습니다.

필자는 이를 다음과 같이 이해했다.
ReactDOM.createPortal()을 통해 렌더링된 컴포넌트는
비록 그것이 html 상에서 root 태그의 직계 자식이 아닐지라도
이벤트, 전역 상태 등 다른 모든 것들을 직계 자식처럼 동일하게 사용할 수 있다.

결론

앞서 개발자 도구를 통해 알아봤듯이,
ReactDOM.createPortal()은 다른 DOM 트리에서 컴포넌트가 렌더링되게 해준다.
이는 CSS 작업에서 다른 컴포넌트로부터 연쇄적으로 영향 받는 것에서 벗어날 수 있게 해준다.
공통적인 UI(모달, 팝업, 알림 등)을 구현할 때 유용하게 사용할 수 있을 것이다.
공식 문서에도 이 활용법을 언급할 정도니 잘 기억해놔야겠다.
html 상 태그를 더 명확하게 구분할 수 있다는 것도 장점이라고 생각한다.

참고 자료

React 공식 docs - Portals
dfd1123님 블로그
jeonghwan-kim님 블로그

profile
나를 믿는 사람들을, 실망시키지 않도록

1개의 댓글

comment-user-thumbnail
2023년 9월 20일

@dnd-kit 살펴보던 중 createPortal 을 사용하는 구문이 있었는데, 덕분에 빠르게 감을 잡을 수 있었습니다. 감사합니다.

답글 달기