Portals 는 리액트 프로젝트에서 컴포넌트를 렌더링하게 될 때, UI 를 어디에 렌더링 시킬지 DOM 을 사전에 선택하여 부모 컴포넌트의 바깥에 렌더링 할 수 있게 해주는 기능입니다.

Portals 가 리액트에 도입된지는 꽤 됐지만 따로 정리한적이 없어서 늦게나마 정리해보겠습니다.

이 기능은 리액트 v16 에서 도입된 기능인데요, 기존의 리액트에서 컴포넌트를 렌더링 하게 될 때, children 은 부모컴포넌트의 DOM 내부에 렌더링 되어야 했었습니다:

const Wrapper = ({ children }) => {
  return <div>{children}</div>;
}

Portals 를 사용하면 DOM 의 계층구조 시스템에 종속되지 않으면서 컴포넌트를 렌더링 할 수 있습니다.

const MyPortal = ({ children }) => {
  const el = document.getElementById('my-portal');
  return ReactDOM.createPortal(children, el);
}

리액트 프로젝트의 엔트리 파일인 src/index.js 파일을 열어보시면 다음 같은 코드가 있습니다:

ReactDOM.render(<App />, document.getElementById('root'));

document.getElementById 함수를 사용하여 id 값이 root 인 DOM 을 찾고, App 컴포넌트를 여기에 렌더링하고 있습니다. 이 DOM 은 public/index.html 을 열어보면 찾아보실 수 있습니다:

  <body>
    <noscript>
      You need to enable JavaScript to run this app.
    </noscript>
    <div id="root"></div>
  </body>

ModalPortal 만들기

한번 모달을 렌더링 할 수 있는 ModalPortal 을 구현해보겠습니다. 이 실습은 다음 CodeSandbox 에서 진행하세요:
Edit portal-sample
혹은, create-react-app 으로 프로젝트를 새로 만드셔서 진행하셔도 무방합니다.

만약 create-react-app 으로 직접 프로젝트를 만드는 경우, App.js 와 App.css 를 다음과 같이 준비해주세요:

src/App.js

import React, { Component } from 'react';
import './App.css';

class App extends Component {
  render() {
    return (
      <div className="App">
        <h1>안녕하세요 리액트!</h1>
      </div>
    );
  }
}

export default App;

src/App.css

.App {
  text-align: center;
  color: #61dafb;
}

프로젝트를 준비하셨으면 public 디렉토리의 index.html 를 열으셔서 <div id="root"></div> 하단에 새로운 DOM 하나를 만들어주세요:

public/index.html

  <body>
    <noscript>
      You need to enable JavaScript to run this app.
    </noscript>
    <div id="root"></div>
    <div id="modal"></div>
  </body>

그리고, ModalPortal 컴포넌트를 생성합니다:

src/ModalPortal.js

import ReactDOM from 'react-dom';

const ModalPortal = ({ children }) => {
  const el = document.getElementById('modal');
  return ReactDOM.createPortal(children, el);
};

export default ModalPortal;

나중에 우리가 이 컴포넌트를 사용하면 우리가 원하는 JSX 를 id="modal" 을 가진 DOM 엘리먼트 에 렌더링을 할 수 있게 됩니다.

MyModal 만들기

이제, 모달을 만들어보겠습니다. MyModal.js 와 MyModal.css 를 다음과 같이 작성해주세요:

src/MyModal.js

import React from 'react';
import './MyModal.css';

const MyModal = () => {
  return (
    <div className="MyModal">
      <div className="content">
        <h3>이것은 모달</h3>
        <p>궁시렁 궁시렁 내용입니다.</p>
        <button>닫기</button>
      </div>
    </div>
  );
};

export default MyModal;

src/MyModal.css

.MyModal {
  background: rgba(0, 0, 0, 0.25);
  position: fixed;
  left: 0;
  top: 0;
  height: 100%;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.MyModal .content {
  background: white;
  padding: 1rem;
  width: 400px;
  height: auto;
}

MyModal 컴포넌트를 만들고나서, 한번 App.js 에서 렌더링해보세요:

src/App.js

import React, { Component } from 'react';
import MyModal from './MyModal';
import './App.css';

class App extends Component {
  render() {
    return (
      <div className="App">
        <h1>안녕하세요 리액트!</h1>
        <MyModal />
      </div>
    );
  }
}

export default App;

image.png
Edit portal-sample

그러면, 위와 같은 결과가 나타나게 됩니다. MyModal 컴포넌트가 App 컴포넌트 내부에 렌더링이 되면서 기존에 App 이 지니고 있던 스타일들을 그대로 전달받게 됐습니다.

이번엔, 아까전에 만들었던 ModalPortal 을 사용하여 App 에서 MyModal 을 렌더링할때 한번 감싸도록 코드를 수정해보겠습니다. 과연 어떤 결과가 나타날까요?

image.png
Edit portal-sample

이제는 MyModal 컴포넌트가 App 내부가 아니라, 그 바깥의 <div id="modal"></div> 안에 렌더링되었습니다.

이렇게 Portal 를 사용하여 불필요한 스타일을 상속받는 문제를 해결할수도있고 그 외에도 엘리먼트의 레이어 위치를 관리하는 z-index 스타일을 관리 할 때 유용하게 사용 될 수도 있습니다.

MyModal 닫기기능 구현

닫기 버튼을 누르면 모달이 사라지게끔 구현해보겠습니다.

App 컴포넌트에서 모달이 보여질지말지 정해주는 modal 값을 state 에 넣고, 이 값을 설정시켜줄 handleOpenModal 과 handleCloseModal 을 작성하세요:

src/App.js

import React, { Component } from 'react';
import MyModal from './MyModal';
import ModalPortal from './ModalPortal';
import './App.css';

class App extends Component {
  state = {
    modal: false
  };
  handleOpenModal = () => {
    this.setState({
      modal: true
    });
  };
  handleCloseModal = () => {
    this.setState({
      modal: false
    });
  };
  render() {
    return (
      <div className="App">
        <h1>안녕하세요 리액트!</h1>
        <button onClick={this.handleOpenModal}>모달 열기</button>
        {this.state.modal && (
          <ModalPortal>
            <MyModal onClose={this.handleCloseModal} />
          </ModalPortal>
        )}
      </div>
    );
  }
}

export default App;

onClose 를 MyModal 한테 전달해주었으니, MyModal 내부에서 버튼 클릭시 호출도 해주어야겠지요?

src/MyModal.js

import React from 'react';
import './MyModal.css';

const MyModal = ({ onClose }) => {
  return (
    <div className="MyModal">
      <div className="content">
        <h3>이것은 모달</h3>
        <p>궁시렁 궁시렁 내용입니다.</p>
        <button onClick={onClose}>닫기</button>
      </div>
    </div>
  );
};

export default MyModal;

이렇게 하고나면 이제 모달을 열고 닫는게 가능해집니다. 바깥 DOM 에 렌더링되어있지만, 상태관리는 평상시 하던 것 처럼 똑같이 처리 할 수 있습니다.

Edit portal-sample

정리

리액트의 Portals 기능을 사용하게되면, 렌더링을 원하는 DOM 에 자유자재로 할 수 있습니다. 참고로 타겟 DOM 이 꼭 App 바깥이 아니여도 됩니다. 리액트 앱내부에서 원래 있어야하는 곳 말고 다른곳에 렌더링하고 싶을때도 동일한 방식으로 하시면 됩니다. 모달 같은 컴포넌트를 만들게 될 때 스타일 관련해서 문제를 겪게 된다면 Portal 을 활용해보시길 바랍니다.