본문 바로가기

이것저것 스터디📚/글쓰기✍️

나만의 동적 Portal 컴포넌트 만들기

1. portal이란?

리액트(React)에서 Portal은 컴포넌트 트리 내에서 다른 위치로 자식 요소를 렌더링할 수 있는 기능이다. portal은 주로 모달 창, 팝오버, 툴팁과 같은 오버레이 컴포넌트를 구현하거나, 외부 DOM 요소에 컴포넌트를 렌더링할 때 사용된다.

 

portal을 사용하면 자식 요소는 DOM 트리의 지정된 위치로 이동되지만, 해당 자식 컴포넌트는 여전히 컴포넌트 트리의 일부로 유지된다.

 

portal은 보통 모달창을 구현할 때, 많이 사용된다.

 

2. portal을 사용하는 이유

portal을 사용하면 컴포넌트가 렌더링되는 위치와 실제로 화면에 표시되는 위치를 분리할 수 있으며, 레이아웃, 스타일링, 이벤트 처리 및 zIndex 관리를 보다 쉽게 할 수 있게 해준다.


3. portal 생성 방식

portal을 생성하려면 react-dom의 creactPortal 함수를 사용한다. createPortal의 두 개의 인수를 받는데, 첫 번째 인수는 portal을 사용해서 렌더링할 자식 요소이고, 두 번째 인수는 container(자식 요소를 렌더링할 대상 DOM 요소)이다.

 

4. portal 사용방법

React의 portal을 사용해서 ErrorModal 컴포넌트를 만드는 예시이다.

 

* index.html

<body>
    <div id="backdrop-root"></div>
    <div id="overlay-root"></div>
    <div id="root"></div>
</body>

* ErrorModal.js

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

import Card from './Card';
import Button from './Button';
import classes from './ErrorModal.module.css';

const Backdrop = (props) => {
  return <div className={classes.backdrop} onClick={props.onConfirm} />;
};

const ModalOverlay = (props) => {
  return (
    <Card className={classes.modal}>
      <header className={classes.header}>
        <h2>{props.title}</h2>
      </header>
      <div className={classes.content}>
        <p>{props.message}</p>
      </div>
      <footer className={classes.actions}>
        <Button onClick={props.onConfirm}>Okay</Button>
      </footer>
    </Card>
  );
};

const ErrorModal = (props) => {
  return (
    <React.Fragment>
      {ReactDom.createPortal(
        <Backdrop onConfirm={props.onConfirm} />,
        document.getElementById('backdrop-root')
      )}
      {ReactDom.createPortal(
        <ModalOverlay title={props.title} message={props.message} onConfirm={props.onConfirm} />,
        document.getElementById('overlay-root')
      )}
    </React.Fragment>
  );
};

export default ErrorModal;

위의 예시는 ModalOverlay와 Backdrop을 portal을 사용해서 렌더링하는 예시이다. 먼저 index.html에 ModalOverlay와 Backdrop 컴포넌트가 렌더링할 container div를 만들고, react-dom의 createPortal을 사용해서 해당 div에 각각 렌더링하는 방법이다.

ErrorModal 컴포넌트 렌더링 전
ErrorModal 렌더링 후

위와 같이 ErrorModal이 렌더링되기 전에는 backdrop-root와 overlay-root div는 자식 요소를 가지고 있지 않은 상태이다. 하지만, ErrorModal이 렌더링되면 각각 Backdrop, ModalOverlay 컴포넌트는 backdrop-root, overlay-root div의 자식 요소로 렌더링이 된다.

 

5. 고민 🤔

portal을 사용해서 root div가 아닌 외부 div로 렌더링을 했을 때, 스타일을 적용하거나 backdrop 컴포넌트에 이벤트를 적용하는 것은 유용해졌지만, ErrorModal 컴포넌트의 렌더링 전에는 사용하지 않는 div 태그가 렌더링되어 있는 모습을 확인할 수 있다.

 

portal에 대해서 학습을 하고 실제 프로젝트에서 portal을 사용해서 Menu 컴포넌트 등을 렌더링해서 사용했는데, 사용하지 않는 div가 남아있는 부분에 대해서 많은 고민을 하였고, 자식 요소를 렌더링할 container div를 동적으로 생성하는 방법을 찾아 적용했다.

 

6. 동적 portal 만들기

// Portal.jsx
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';

const Portal = ({ id, children }) => {
  const modalDiv = useRef(
    document.getElementById(id) || document.createElement('div')
  );

  const dynamicDiv = !modalDiv.current.parentElement;

  useEffect(() => {
    if (dynamicDiv) {
      modalDiv.current.id = id;
      document.body.appendChild(modalDiv.current);
      modalDiv.current.classList.add('modal-root');
    }

    return () => {
      modalDiv.current.remove();
    };
  }, [id]);

  return createPortal(
    <S.PortalWrap>
      <S.Portal>{children}</S.Portal>
    </S.PortalWrap>,
    modalDiv.current
};
// Menu.jsx
import { useState } from "react";
import Portal from './Portal';

const Menu = () => {
  ...
  return (
    <Portal id="modal-root">
      ...
    </Portal>
  );
};

export default Menu;

 

먼저 프로젝트에서 만들고 적용했던 Portal 컴포넌트의 전체 코드는 위와 같다.

 

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

먼저 Portal을 사용하려면 react-dom의 createPortal 함수를 사용해야 하기 때문에 import를 해준다.

 

const Portal = ({ id, children }) => {
  const modalDiv = useRef(
    document.getElementById(id) || document.createElement('div')
  );

  const dynamicDiv = !modalDiv.current.parentElement;

  useEffect(() => {
    if (dynamicDiv) {
      modalDiv.current.id = id;
      document.body.appendChild(modalDiv.current);
      modalDiv.current.classList.add('modal-root');
    }

    return () => {
      modalDiv.current.remove();
    };
  }, [id]);

  return createPortal(
    <S.PortalWrap>
      <S.Portal>{children}</S.Portal>
    </S.PortalWrap>,
    modalDiv.current
  );

 

Portal 컴포넌트를 살펴보면 먼저 props로 id와 children을 받고 있다. 먼저 id의 경우에는 useRef를 사용해서 만약 전달받은 id와 일치하는 div 태그가 이미 존재하는 경우 해당 div를 portal의 container로 정하고 반대의 경우에는 새로운 div 태그를 생성해서 container로 정한다.

 

다음으로는 modalDiv의 부모 요소가 없는 경우, 즉 prop으로 받은 id와 일치하는 div 태그가 아닌 새로운 div 태그를 만드는 경우에는 새로 만든 div 태그 id에 prop으로 전달받은 id를 전달, html의 body 태그의 자식으로 만들고, css 적용을 위해 classList에 class를 추가했다.

 

그리고 만약 Portal 컴포넌트가 언마운트되는 경우에는 5. 고민의 내용처럼 사용하지 않는 div 태그를 제거하기 위해 useEffect의 클린업 함수를 이용하여 동적으로 생성된 div를 제거해주었다.

 

7. 느낀점

처음 portal을 학습했을 때에는 굳이 portal을 사용해야 하는가에 대한 의문이 있었다. 하지만 실제로 프로젝트에서 적용을 했을 때 모달을 화면 중앙에 위치시키는 등 스타일을 적용하는데 편리함이 있다는 것을 알게 되었고, backdrop 을 추가했을 때 모달이 아닌 backdrop을 클릭했을 때 이벤트를 정의하는 방법도 훨씬 수월하다는 것을 알게 되었다.

 

또한, 단순히 portal만을 사용하지 않고 나만의 동적 portal을 만들어서 사용해보았을 때, 개발 의도대로 동작하는 부분이 많이 신기하고 재밌기도 했다. 

 


참고 :  https://medium.com/@jc_perez_ch/dynamic-react-portals-with-hooks-ddeb127fa516

 

Dynamic React Portals with hooks

I struggled finding a good example online on how to create a Portal which renders into a DOM element, uniquely identified with an id. In…

medium.com

[Udemy] React 완벽 가이드(https://www.udemy.com/course/best-react/)

'이것저것 스터디📚 > 글쓰기✍️' 카테고리의 다른 글

CSR, SSG, SSR  (0) 2023.10.15