모달

이현섭·2023년 10월 14일
0

개발을 하다보면 모달을 자주 사용하게 되는데 모달을 사용하는 컴포넌트 마다 항상 반복적으로 사용했던 경험이 많음.

export default function Component () {
	const [modalOpen, setModalOpen] = useState(false);
  
  	return (
		<div>
        	<div>컴포넌트</div>
        	{modalOpen && <모달 컴포넌트/>}
		</div>
	)

}

처음에는 이런식으로 사용했었는데, 모달을 사용하는 컴포넌트에서 반복적으로 작성해야한다.
이를 개선하려면?

=> 모달 상태 전역으로 관리하자!

코드의 반복성을 줄이고, 효율적으로 모달컴포넌트를 작성해보자.

  • 각 모달은 고유의 ID를 가지고있음.
  • 메인 앱 DOM 계층구조와 독립적으로 모달을 렌더링하기 위해 React Portal을 사용.

해당 예제는 Next.js 와 Jotai 사용.

1. 모달 전역 상태 작성

// atom.ts

interface IModalAtom {
    [fileName: string]: {
        open: boolean;
    };
}

export const modalAtom = atom<IModalAtom>({
    testModal: {
        open: false,
    },
});

키 값으로 파일 이름을 지정해주고 현재는 open이라는 속성만 넣어줬습니다.
키 값으로 파일이름을 지정해준 이유는 Dynamic Import를 할 때 해당 파일 이름으로 import하기 위함입니다.

2. 해당하는 ID의 모달 상태가 Open일 경우에만 모달컴포넌트를 렌더링 시키기 위해 LazyComponent 작성

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의 조합으로 변경 가능합니다.

3. Modal Provider 작성

"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;

4. BaseModal 컴포넌트 작성

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,
            )}
        </>
    );
};

4. 실제 사용할 Modal 컴포넌트 작성

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;

5. 모달 상태를 관리하기 위한 Custom hook

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의 상태를 변경해주는 함수를 작성해줍니다.

6. 실제 사용

"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

profile
안녕하세요. 프론트엔드 개발자 이현섭입니다.

0개의 댓글