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