Skip to main content

Command Palette

Search for a command to run...

createPortal 로 모달 깎기

Published
5 min readView as Markdown

createPortal ??

createPortal(children, domNode, key?)

일부 하위 요소를 DOM의 다른 부분으로 렌더링할 수 있게 해줌

관심이 간 이유 !

Context API로 모달을 만들어서 쓰고 있었다.. 그러나.. 뎁스가 깊어지면 부모(혹은 조부모) 컴포넌트의 width, height에 따라 의도대로 동작하지 않는 문제가 발생했다. 예를 들자면 헤더의 높이에 모달이 갇혀버려서 영영 백그라운드만 보이게 되는 현상도 겪었고.. boolean값에 따라 모달을 띄워 주어야 하는 상황에서 modal의 children의 타입에 대한 처리를 따로 해주어야하는 상황도 있었고.. 앱의 디자인을 전부 웹으로 구현하려다보니 겪은 문제였지만 어쨌든 context로 만든 모달의 한계를 알 수 있었던 트러블슈팅이었다..

createPortal“이미 존재” 하는 node에 엘리먼트를 렌더링할 수 있게 해준다. 즉, 만일 root와 동일한 선상에 portal을 생성한다면, 내가 겪었던 부모노드의 스타일에 따라 갇히는 문제가 발생할 일이 없어진다.(최상단에 있기 때문이겠지..!?)

세팅하기

React와 Next에서 설정해주는 법이 각각 다르다. (당연함..)

React

App.tsx파일에서 portal을 생성할 div를 만들어준다.

// App.tsx

import GlobalStyle from "./GlobalStyle";
import Router from "./shared/Router";
import Layout from "./shared/styles/Layout";

function App() {
  return (
    <Layout>
      <div id="portal"></div>
      <GlobalStyle />
      <Router />
    </Layout>
  );
}

export default App;

Next

page router

_documet.tsx에 추가

import { Html, Head, Main, NextScript } from "next/document";

export default function Document() {
  return (
    <Html lang="en">
      <Head />
      <body>
        <Main />
        <div id="portal"></div>
        <NextScript />
      </body>
    </Html>
  );
}

app router

최상단의 layout.tsx에 추가

// layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <div id="portal"></div>
      <body className={inter.className}>{children}</body>
    </html>
  );
}

그럼 이제 portal을 만들어보자

// Portal.tsx

"use client";

import React, { ReactNode, useEffect, useState } from "react";
import { createPortal } from "react-dom";

interface PortalProps {
  isOpen: boolean;
  setIsOpen?: () => void;
  children: ReactNode;
}

export default function Portal({ isOpen, children, setIsOpen }: PortalProps) {
  const [portal, setPortal] = useState<HTMLElement | null>();
  useEffect(() => {
    if (document) {
      const portalElem = document.getElementById("portal");

      setPortal(portalElem);
    }
  }, []);
  if (typeof window === "undefined") return <></>;

  if (!isOpen) {
    return null;
  }
  return createPortal(children, portal as HTMLElement);
}

그리고 이를 사용한 Modal 컴포넌트를 만들어보자

// Modal.tsx

"use client";

import React, { ReactNode, useEffect, useState } from "react";
import Portal from "./Portal";

interface ModalProps {
  children: ReactNode;
  content: JSX.Element;
  isConfirm?: boolean;
}

const Modal = ({ children, content, isConfirm = false }: ModalProps) => {
  const [open, setOpen] = useState<boolean>(false);
  if (typeof window === "undefined") return <></>;

  const modalHandler = () => {
    setOpen((pre) => !pre);
  };

  const Content = () => {
    return React.cloneElement(content, { close: modalHandler });
  };

  return (
    <div>
      <div onClick={modalHandler}>{children}</div>
      <Portal isOpen={open}>
          <div
            style={{
              backgroundColor: "rgba(0,0,0,0.4)",
              width: "100vw",
              height: "100vh",
              position: "fixed",
              left: "50%",
              top: "50%",
              transform: "translate(-50%, -50%)",
              zIndex: 10,
            }}
            onClick={() => {
              !isConfirm && modalHandler();
            }}
          />
          <Content />
      </Portal>
    </div>
  );
};

export default Modal;

일단 매번 모달의 사용처에서 useState로 open에 대한 값을 처리해주는 게 번거롭다고 생각해서 모달 자체에서 그 상태를 관리할 수 있도록 처리하고싶었다.

isConfirm은 모달의 background를 클릭할 때, 모달이 꺼지는가 아닌가를 구분해주기 위함이다.

props로 전달받은 conent(모달의 내용이 담긴 컴포넌트)에 상태값을 어떻게 전해줄 지 고민하다가, cloneElement라는 것을 알게되었따!(하지만 리액트에서 권장하는 방법은 아니다..)

cloneElement

cloneElement(element, props, ...children)

이것을 사용하면 props를 override할 수 있어진다..!! 추천하는 방법은 아니지만 나로서는 최선이었던..

내용물 예시

// Box.tsx

"use client";

import React from "react";

interface BoxProps {
  close?: () => void;
}

export default function Box({ close }: BoxProps) {
  const trigger = () => {
    return close && close();
  };

  return (
    <div
      style={{
        width: 500,
        height: 500,
        backgroundColor: "white",
        position: "fixed",
        top: "50%",
        left: "50%",
        transform: "translate(-50%, -50%)",
        zIndex: 15,
        color: "black",
      }}
    >
      이것은 모달입니다.
      <button onClick={trigger}>닫아줘!</button>
    </div>
  );
}

모달에 content로서 내용을 전달해 줄 때에는 close라는 open에 대한 상태를 조절하는 함수를 전달해줄 수 없으므로, optional하다고 정의해준다. 또한 매번 모달을 끄는 버튼에 close && close() 이라는 코드를 작성하긴 다소 귀찮은 관계로 trigger라는 함수로 만들어주었다.

근데 매번 모달의 내용물을 만들 때 이렇게 해주지않을 순 없을까에 대해서도 많이 고민해봤지만.. 닫기버튼, 취소버튼, 서버에 요청하면서 닫는 버튼 등 여러가지 끄기 버튼이 있을때를 생각한다면 어쩔 수 없이 저렇게 해주어야 할 것 같다. 다만 저 타입을 따로 파일로 빼서 모달 내용 컴포넌트를 조금더 효율적으로 만들 수는 있을 것 같다. 뭐 아직까지는이게 최선의 방법이라고 생각한다.

사용법

<Modal content={<Box />}>
    <button>click me !</button>
</Modal>

스크롤을 방지하는 법

(블로그를 참고했는데 지금당장 그 링크를 못찾겠다. 찾는대로 출처 추가할것..)

utils/scroll.ts

export const preventScroll = () => {
  const currentScrollY = window.scrollY;
  document.body.style.position = "fixed";
  document.body.style.width = "100%";
  document.body.style.top = `-${currentScrollY}px`; // 현재 스크롤 위치
  document.body.style.overflowY = "scroll";
  return currentScrollY;
};

export const allowScroll = (prevScrollY: number) => {
  document.body.style.position = "";
  document.body.style.width = "";
  document.body.style.top = "";
  document.body.style.overflowY = "";
  window.scrollTo(0, prevScrollY);
};

hooks/usePreventScroll.ts

import { useEffect } from "react";
import { allowScroll, preventScroll } from "../utils/scroll";

const usePreventScroll = () => {
  useEffect(() => {
    const prevScrollY = preventScroll();
    return () => {
      allowScroll(prevScrollY);
    };
  }, []);
};

export default usePreventScroll;

그리고 보다가 아무리생각해도 portal 파일이 따로 있는게 너무 거슬린 나머지 하나로 합쳤다.. 그냥 파일만 합친거지만 내 보물함에서 복붙에서 쓰기에는 이게 더 좋을것같다..

최종

"use client";

import { usePreventScroll } from "@/hooks";
import React, { ReactNode, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import * as St from "./Modal.css";

interface PortalProps {
  isOpen: boolean;
  children: ReactNode;
}

function Portal({ isOpen, children }: PortalProps) {
  const [portal, setPortal] = useState<HTMLElement | null>();
  if (typeof window === "undefined") return <></>;

  useEffect(() => {
    if (document) {
      const portalElem = document.getElementById("portal");

      setPortal(portalElem);
    }
  }, []);

  if (!isOpen) {
    return null;
  }
  return createPortal(children, portal as HTMLElement);
}

interface ModalProps {
  children: ReactNode;
  //   content: JSX.Element;
  content: JSX.Element;
  isConfirm?: boolean;
}

export default function Modal({
  children,
  content,
  isConfirm = false,
}: ModalProps) {
  const [open, setOpen] = useState<boolean>(false);
  if (typeof window === "undefined") return <></>;

  const modalHandler = () => {
    setOpen((pre) => !pre);
  };

  const Content = () => {
    usePreventScroll();
    return React.cloneElement(content, { func: modalHandler });
  };

  return (
    <div>
      <div onClick={modalHandler}>{children}</div>
      <Portal isOpen={open}>
        <div
          className={St.background}
          onClick={() => {
            !isConfirm && modalHandler();
          }}
        />
        <Content />
      </Portal>
    </div>
  );
}

아직 남은 의문점

mui같은 ui라이브러리에서는 모달을 어떻게 구현하는지 궁금해짐. 저런 라이브러리들의 스토리북을 참고하면 집도 안나가고 잘만 띄워지는데, 내가 만든 스토리에서는 축소해서 띄웠을 때 애가 자꾸 집나감..(실제 사용에는 문제 없음..) 스토리북 설정의 문제인지 모달의 문제인지는 모르겠지만,, 아직 의문으로 남아있음..

More from this blog

번들링 최적화를 통한 import cost 줄이기

최근 팀에서 개발한 디자인 시스템 라이브러리를 프로젝트에 적용하고 사용하면서 흥미로운 문제를 발견했다.(사실 전부터 알고있었지만 잠깐 미뤄뒀다.) 단순히 Button 컴포넌트 하나만 필요했는데, 번들 분석 도구를 확인해보니 라이브러리 전체가 번들에 포함되어 있었던 것이다. 단 하나의 컴포넌트를 위해 수백 KB의 코드가 추가되어버린 것.. // 예상: Button 컴포넌트만 가져오기 import { Button } from '@company/de...

Mar 11, 20256 min read45

package.json 진입점 설정과 타입 접근성의 관계 이해하기

JavaScript와 TypeScript 패키지를 개발할 때 package.json의 진입점 설정은 단순히 어떤 파일이 먼저 로드될지를 결정하는 것 이상의 의미를 갖는다. 특히 TypeScript를 사용하는 프로젝트에서는 타입 접근성에 중요한 영향을 미친다. 회사 패키지를 수정하다가, “@패키지/dist/…” 와 같은 임포트 경로가 너무 프로젝트 내부의 구조등을 대놓고 보여주고 있기도 하고, 무엇보다 보기에 거슬려서 이를 해결하기 위해 type...

Mar 5, 20254 min read15

[2월 회고] 우당탕탕 반백수 2월

업무 이직 첫출근 2/26. 실업급여 퇴사 일주일 뒤에 새 회사에 출근을 하게 됐다. 사실 2주 정도 실업급여를 받고 나서 입사를 했다면 “조기취업수당”이라는 것을 통해 돈을 더 받을수 있었는데, 몇백만원 더 받자고 집에서 더 놀기에는 엉덩이가 들썩거리기도 하고 집에서 뇌가 굳어가는 것 같아 하루라도 빨리 입사를 하고싶었다. 그리고 나는 갈 회사가 있어서 사실상 받을 필요가 없으니 실업급여가 정말 필요한 사람들이 받아가는 게 더 맞지 않...

Feb 28, 20247 min read264
[2월 회고] 우당탕탕 반백수 2월

[1월 회고] 비전공자 출신 신입이었던 내가 6개월만에 칼이직러가 될수있었던 이유 …더보기

신년 맞이(?) 회사 경영악화로 인한 인원축소가 일어나, 잘 다니던 직장을 하루아침에 잃게되었다. 그 과정에서 다소 혼란스럽지만 먹고는 살아야겠기에 열심히(?) 이직준비..를 했던것 같다. 사실 처음에는 왜 내가 정리돼야하는지 현실부정 및 자괴감에 빠져서 아무것도 하기싫어병에 걸렸었는데, 요즘 채용시장이 안좋아서 이력서도 서류탈락의 연속이었던지라 절망의 늪에서 허우적대며 더더욱 깊이 빠져가던 차에 서류합격한 한 회사에 올인했다. 지나고나서야 하...

Feb 7, 20245 min read624

1nxeo's devlog

11 posts