트러블이슈/이약저약

createPortal로 모달을 바깥에 띄우기

Ahyeon, Jung 2024. 5. 30. 02:31


컴파운드 컴포넌트 패턴을 사용해보고, 비즈니스 로직 분리하는 것 때문에 몇번을 갈아엎었던 모달 컴포넌트다. 하지만 z-index 관점에서 한번 더 리팩토링을 해야한다. 모달을 어디서 불러야하는가?

 

현재 모달의 위치

일단은 React의 컴포넌트 루트인 <div id="root"/> 아래에,

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <ErrorBoundary FallbackComponent={Error}>
      <Suspense fallback={<Loading isFullLoading />}>
        <QueryClientProvider client={queryClient}>
          <App />
          <ToastContainer />
        </QueryClientProvider>
      </Suspense>
    </ErrorBoundary>
  </React.StrictMode>,
);

 

라우팅되어 <MedicineDetail/> 아래에,

const router = createBrowserRouter([
  {
    path: "/",
    element: <Layout />,
    errorElement: <section>잘못된 접근입니다</section>,
    children: [
      { index: true, element: <OnBoarding /> },
      { path: routerpaths.HOME, element: <MainPage /> },
      { path: routerpaths.SEARCH, element: <MedicineSearch /> },
      { path: routerpaths.DETAILIDPAGE, element: <MedicineDetail /> },
      {
        path: routerpaths.LOGIN,
        element: <LoginPage />,
        loader: checkTokenAndRedirectToHome,
	  },
      ...
	],
  },
]);

 

<MedicineDetail /> 아래의 <ReviewBoard /> 아래에

export default function MedicineDetail() {
  ...
  return (
      <section className={styles.container}>
        <MedicineCard />
        <div className={styles.board}>
          <TapBar taps={TAPS} onClick={handleTapClick} />
          {currentTapValue === TAPS_QUERIES.REVIEW && (
            <ReviewBoard medicineId={medicineId} />
          )}
          {(currentTapValue === TAPS_QUERIES.INFO ||
            currentTapValue === null) && (
            <InfoBoard
              howToEat={howToEat}
              ingredient={ingredient}
              describe={describe}
            />
          )}
        </div>
      </section>
	);
}

 

<ReviewBoard />의 내부에서 <ReviewPostModal />을 불러오고 있고,

export default function ReviewBoard({ medicineId }: { medicineId: number }) {
  return (
    <div>
      <SelectSort
        currentSort={currentSort}
        handleCurrentSort={handleCurrentSort}
      >
        <SelectSort.SortCurrentOption />
        <SelectSort.SortOptionList>
          {REVIEW_SORT_OPTIONS.map((sort) => (
            <SelectSort.SortOption
              key={sort.value}
              value={sort.value}
              label={sort.label}
            />
          ))}
        </SelectSort.SortOptionList>
      </SelectSort>
      <div className={styles["container"]}>
	    <div className={styles["reviews-container"]}>
          {isZero(reviews.length) ? (
            <div style={{ marginTop: "250px" }}>
              <BlankBox text="작성된 리뷰가 없습니다" />
            </div>
            ) : (
            reviews.map((reviewItem) => (
            <ReviewBoardItem key={reviewItem.id} reviewItem={reviewItem} />
            ))
          )}
        </div>
        <ReviewPostModal />
      </div>
    </div>
	);
}

 

<ReviewBoard />의 내부에서 <ReviewBordItem /> 마저 모달이고, 모달 내에서 또 <PopupModal />을 불러오고 있다.  

export default function ReviewBoardItem({ reviewItem }: { reviewItem: ReviewItemType }) {
    ...
	return (
      <>
        <Modal
          isOpen={isOpen}
          onClose={onClose}
          toggleOpen={toggleOpen}
          onOpen={onOpen}
        >
          <Modal.Trigger>
            <div className={styles.container}>
              <Title userId={reviewItem.createdBy.userId} ... />
              <div className={styles["review-container"]}>
                ...
              </div>
            </div>
          </div>
        </div>
      </Modal.Trigger>
      <Modal.Content>
        <ReviewDetail
          reviewId={reviewId}
          handleOpenConfirmDelete={() => {
            setIsOpenConfirmDelete(true);
          }}
        />
      </Modal.Content>
    </Modal>
    {isOpenConfirmDelete && (
      <PopupModal
        middleIcon={<FiAlertTriangle />}
        text="정말로 삭제하시겠습니까?"
        onClose={() => {
          setIsOpenConfirmDelete(false);
        }}
        onCancel={() => {
          setIsOpenConfirmDelete(false);
        }}
        onClick={() => {
          deleteReviewMutation();
        }}
      />
    )}
  </>
  );
}

 

한 페이지 내부에서 리뷰 작성 모달, 리뷰 조회 모달, 리뷰 편집 모달, 리뷰 삭제 안내 모달이 공존하고 있는 것이다. 리뷰 조회와 편집은 Content 내부에서 isEditing 상태 조건으로 처리했지만, 여전히 3개의 모달이 리액트 루트의 내부에, 내부에, 내부로 엮여 있었다.

z-index로 쌓임 맥락 씌우기

단순하게 접근하면, z-index를 통해 모달의 순서를 조정하여 각각의 모달에 부여하면, 높은 z-index를 가진 모달이 가장 앞쪽에 표시된다.

 

/** Z-index */
$z-index-nav: 999;
$z-index-modalBackground: 998;
$z-index-modalContainer: 1;
$z-index-userInfo: 1000;
$z-index-select: 500;

 

그러나 생각해보자. 가장 하단에 있는 리뷰 삭제 모달을 단순히 스타일링을 통해 최상단으로 끌어올리는게 그냥 눈가리고 아웅하는게 아닐까? 나중에 코드를 볼 때 view와 달리 가장 하단에 있는 모달을 쉽게 찾아낼 수 있을까?  z-index를 설정해준다고 해도 관리하지 못하는 등 DOM 트리 내에서 여러가지 충돌이 있지 않을까?

 

답은 간단하다. 모달을 끌어올려 <div id="root" /> 바깥으로 보내는 것이다.

 

React.createPortal

Portal은 부모 컴포넌트의 DOM 계층 구조 바깥에 있는 DOM 노드로 자식을 렌더링한다. 컴포넌트를 DOM의 다른 부분으로 렌더링하는 역할이다. 일반적으로 컴포넌트는 부모 컴포넌트의 DOM 계층 구조 내에서 렌더링되지만, 포털을 사용하면 이를 벗어나 다른 DOM 노드에 렌더링할 수 있다. 첫 번째 인자는 엘리먼트, 문자열, 혹은 fragmen와 같은 React.childNode이며, 두 번째 인자는 DOM 엘리먼트를 받는다. 모달, 툴팁, 드롭다운 메뉴와 같은 UI 요소를 구현할 때 매우 유용한 API이다.

 

즉, createPortal을 사용하 컴포넌트 루트의 형제로 모달을 끌어올릴 수 있다.

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

 

그래서 Modal을 ReactDOM.createPortal을 통해서 modal-root에 사용해줬다.

/** 외부에서 isOpen 상태 받아옴 */
export default function ModalRoot({
	isOpen,
	onOpen,
	onClose,
	toggleOpen,
	children,
}: ModalRootProps) {
	const value = { isOpen, onOpen, onClose, toggleOpen };
	return ReactDOM.createPortal(
		<ModalContext.Provider value={value}>{children}</ModalContext.Provider>,
		document.getElementById("modal-root")!,
	);
}

/** 내부에서 isOpen 상태 생성 */
export function ModalWithOpen({ children }: { children: React.ReactNode }) {
	const { isOpen, onClose, onOpen, toggleOpen } = useToggle();

	const value = { isOpen, onOpen, onClose, toggleOpen };

	return ReactDOM.createPortal(
		<ModalContext.Provider value={value}>{children}</ModalContext.Provider>,
		document.getElementById("modal-root")!,
	);
}

interface ModalTemplateProps {
	trigger: React.ReactElement;
	content: React.ReactElement;
	close: boolean;
}

/** 외부에서 모든 정보를 받아오는 가이드 */
export function ModalTemplate({ trigger, content, close }: ModalTemplateProps) {
	const { isOpen, onClose, onOpen, toggleOpen } = useToggle();

	const value = { isOpen, onOpen, onClose, toggleOpen };

	return (
		<ModalContext.Provider value={value}>
			<Trigger>{trigger}</Trigger>
			<Content>{content}</Content>
			{close && <Close />}
		</ModalContext.Provider>
	);
}

 

그러면 이렇게된다.

 

당연하다. Trigger까지 다 <div id="modal-root"/>로 끌어올려졌을테니. 이런 점에서 다시 DOM 바깥으로 보낼 부분과 내부에서 사용할 부분을 분리해야한다.

 

ChakraUI를 쓸 때 Overlay가 따로 있어도 필요성을 못느껴서 안썼고 마찬가지로 컴포넌트를 구성할 때도 background를 굳이 컴포넌트 UI로 분리해야하나? 하는 의문이 있어서 굳이 따로 분리안했는데, 이러한 이유가 있었던 거시다..

적용하기

ModalRoot 내부에서 Trigger와 DOM 요소 바깥으로 들어갈 영역을 Overlay로 감싼 형태로 만들어주면 된다.

export default function Overlay({ children }: ModalRootProps) {
	const { isOpen, toggleOpen } = useContext(ModalContext);

	if (!isOpen) return null;

	return ReactDOM.createPortal(
		<div className="background" onClick={toggleOpen}>
			{children}
		</div>,
		document.getElementById("modal-root")!,
	);
}

 

Close를 사용하지 않는다면 Content에서 해도 될 것 같지만, 그렇게 되면 Content가 너무 많은 책임을 갖고 있기 때문에 Overlay를 통해 분리해주는 것이 좋다.

export function ModalTemplate({ trigger, content, close }: ModalTemplateProps) {
	const { isOpen, onClose, onOpen, toggleOpen } = useToggle();

	const value = { isOpen, onOpen, onClose, toggleOpen };

	return (
		<ModalContext.Provider value={value}>
			<Trigger>{trigger}</Trigger>
			<Overlay>
				<Content>{content}</Content>
				{close && <Close />}
			</Overlay>
		</ModalContext.Provider>
	);
}

 

개발자 도구를 통해 각각이 형제 노드로 분리된 것을 확인할 수 있다.


쉽게 말해서, css로 최상단인척하던 모달을 진짜로 DOM트리 내에서 최상단으로 끌어올린 것이라고 보면된다. app-root 안에 띄우게 되면 외부에서 어떠한 영향을 받을지 모르고, z-index가 서로 충돌이 날 수 있기 때문에 이러한 점을 방지하기 위해서 createPortal을 사용한다. context에서 문제가 생기려나 싶었는데, 리액트 포털은 물리적인 위치만 변경하고, 자식은 부모 트리가 제공하는 컨텍스트에 접근이 가능하고 이벤트는 React tree에 따라 자식에서 부모로 버블링된다고 한다.


하지만 여기서 끝나면 조금 서운하다.

코드를 보자

 

GitHub - facebook/react: The library for web and native user interfaces.

The library for web and native user interfaces. Contribute to facebook/react development by creating an account on GitHub.

github.com

import {createPortal as createPortalImpl} from 'react-reconciler/src/ReactPortal';

function createPortal(
  children: ReactNodeList,
  container: Element | DocumentFragment,
  key: ?string = null,
): React$Portal {
  if (!isValidContainer(container)) {
    throw new Error('Target container is not a DOM element.');
  }

  return createPortalImpl(children, container, null, key);
}
export const REACT_PORTAL_TYPE: symbol = Symbol.for('react.portal');

export function createPortal(
  children: ReactNodeList,
  containerInfo: any,
  implementation: any,
  key: ?string = null,
): ReactPortal {
  if (__DEV__) {
    checkKeyStringCoercion(key);
  }
  return {
    $$typeof: REACT_PORTAL_TYPE,
    key: key == null ? null : '' + key,
    children,
    containerInfo,
    implementation,
  };
}

 

$$typeof를 통해서 포탈 객체임을 알리고, React는 이 포탈 객체를 인식하여 children을 containerInfo 위치에 렌더링한다. 사실 도대체 어떤 친구길래 이벤트 버블링은 유지하면서 부모 컴포넌트 바깥으로 빼는건지가 궁금했는데 REACT_PORTAL_TYPE에서 어디로 넘어가야하는지 모르겠다.

<dialog /> 태그

html에 이미 <dialog /> 요소로 닫을 수 있는 경고, 검사기, 창 등 대화 상자 및 기타 다른 상호작용 가능한 컴포넌트를 나타낼 수 있는 태그가 있었다.

<dialog>: 대화 상자 요소 - HTML: Hypertext Markup Language | MDN (mozilla.org)

 

<dialog>: 대화 상자 요소 - HTML: Hypertext Markup Language | MDN

HTML <dialog> 요소는 닫을 수 있는 경고, 검사기, 창 등 대화 상자 및 기타 다른 상호작용 가능한 컴포넌트를 나타냅니다.

developer.mozilla.org

 

Reference

Portals – React (reactjs.org)

https://velog.io/@jerrychu/React-React-Portal-%EC%82%AC%EC%9A%A9-%EC%9D%B4%EC%9C%A0%EC%99%80-%EB%B0%A9%EB%B2%95