개발을 하다보면 모달을 자주 사용하게 되는데 모달을 사용하는 컴포넌트 마다 항상 반복적으로 사용했던 경험이 많음.
export default function Component () {
const [modalOpen, setModalOpen] = useState(false);
return (
<div>
<div>컴포넌트</div>
{modalOpen && <모달 컴포넌트/>}
</div>
)
}
처음에는 이런식으로 사용했었는데, 모달을 사용하는 컴포넌트에서 반복적으로 작성해야한다.
이를 개선하려면?
=> 모달 상태 전역으로 관리하자!
코드의 반복성을 줄이고, 효율적으로 모달컴포넌트를 작성해보자.
해당 예제는 Next.js 와 Jotai 사용.
// atom.ts
interface IModalAtom {
[fileName: string]: {
open: boolean;
};
}
export const modalAtom = atom<IModalAtom>({
testModal: {
open: false,
},
});
키 값으로 파일 이름을 지정해주고 현재는 open이라는 속성만 넣어줬습니다.
키 값으로 파일이름을 지정해준 이유는 Dynamic Import를 할 때 해당 파일 이름으로 import하기 위함입니다.
import useModal from "@/hooks/useModal";
import dynamic from "next/dynamic";
interface ILazy {
filename: string;
}
interface IDynamicComponent {
onClose: () => void;
}
const Lazy = (props: ILazy) => {
const { filename } = props;
const { onClose } = useModal(filename);
const handleModalClose = () => {
onClose();
};
const Component = dynamic<IDynamicComponent>(
() => import(`../${filename}/${filename}.tsx`),
);
return <Component onClose={handleModalClose} />;
};
export default Lazy;
Lazy Component는 filename을 props으로 받습니다. 폴더 구조에 따라 import되는 부분의 경로는 변경되며, 현재 Next.js를 사용하고있어서 dynamic 함수를 사용했는데
React.lazy() 와 Suspense의 조합으로 변경 가능합니다.
"use client";
import Lazy from "@/components/lazy/lazy";
import { modalAtom } from "@/jotai/atom";
import { useAtomValue } from "jotai";
interface IModalProvider {
children: React.ReactNode;
}
const ModalProvider = (props: IModalProvider) => {
const modalState = useAtomValue(modalAtom);
const modals = Object.keys(modalState).filter((item) => modalState[item].open);
return (
<>
{modals.map((filename) => (
<Lazy key={filename} filename={filename} />
))}
{props.children}
</>
);
};
export default ModalProvider;
상태관리는 현재 Jotai로 사용하고있는데 Redux, Recoil .. 사용하시는 전역상태로 변경가능합니다.
전역상태에서 open의 상태가 true인 것들만 Lazy 컴포넌트로 렌더링 해줍니다.
그리고 해당하는 ModalProvider는 layout에서 감싸줬습니다.
// layout.tsx
const RootLayout = ({ children }: { children: React.ReactNode }) => {
return (
<html lang="ko">
<body className={noto_Sans_KR.className}>
<JotaiProvider>
<ModalProvider>{children}</ModalProvider>
</JotaiProvider>
</body>
</html>
);
};
export default RootLayout;
import { createPortal } from "react-dom";
import style from "./baseModal.module.scss";
import { MouseEventHandler } from "react";
interface IBaseModal {
children: React.ReactNode;
onClose: () => void;
}
export const BaseModal = (props: IBaseModal) => {
const { children, onClose } = props;
const body = document.body;
if (!body) throw new Error("Cannnot Find Body");
const handleInsideClick: MouseEventHandler<HTMLDivElement> = (event) => {
event.stopPropagation();
};
const handleOutsideClick: MouseEventHandler<HTMLDivElement> = () => {
if (onClose) {
onClose();
}
};
return (
<>
{createPortal(
<div className={style.modal_container} onClick={handleOutsideClick}>
<div className={style.modal_content_wrapper} onClick={handleInsideClick}>
{children}
</div>
</div>,
body,
)}
</>
);
};
import { BaseModal } from "../baseModal/baseModal";
interface ITestModal {
onClose: () => void;
}
const TestModal = (props: ITestModal) => {
const { onClose } = props;
return (
<BaseModal onClose={onClose}>
<div style={{ width: "300px", height: "300px", backgroundColor: "red" }}>
테스트모달
</div>
</BaseModal>
);
};
export default TestModal;
import { modalAtom } from "@/jotai/atom";
import { useSetAtom } from "jotai";
import { useCallback } from "react";
const useModal = (modalFileName: string) => {
const setModalState = useSetAtom(modalAtom);
const toggleModal = useCallback(
(isOpen: boolean) => {
setModalState((prev) => ({
...prev,
[modalFileName]: { ...prev[modalFileName], open: isOpen },
}));
},
[modalFileName],
);
const onOpen = () => toggleModal(true);
const onClose = () => toggleModal(false);
return { onOpen, onClose };
};
export default useModal;
파일 이름을 인자로 받아와 해당하는 id의 상태를 변경해주는 함수를 작성해줍니다.
"use client";
import useModal from "@/hooks/useModal";
export default function ModalTestComponent() {
const { onOpen, onClose } = useModal("testModal");
const handleClick = () => {
onOpen();
};
return (
<div style={{ position: "absolute", top: "10px", right: "10px" }}>
<button onClick={handleClick}>모달생성</button>
</div>
);
}
작성한 코드의 폴더구조는 위와 같습니다.
참고자료 :
https://hackernoon.com/the-perfect-react-modal-implementation-for-2023
https://nextjs.org/docs/app/building-your-application/optimizing/lazy-loading