외주 프로젝트 중 여러 알림을 쌓으면서 관리해야 하는 Alert, 한 번에 하나만 띄워져야 하는 Modal을 useModal이라는 하나의 훅을 이용해 관리하기 위해 작성해 보았다.
코드의 흐름 설명
- 모달 상태 관리(useModalStore)
- useModalStore는 Zustand를 사용해 모달의 상태를 관리하며, stack 옵션에 따라 모달의 관리 방식을 조정합니다.
- modals: 현재 열려 있는 모달의 배열입니다.
- open: 특정 컴포넌트를 모달로 열고, 관련 데이터를 상태에 추가합니다.
- stack: true: modals 배열에 새로운 모달을 추가합니다.
- stack: false: 기존 모달이 없을 때만 추가. 기존 모달이 있다면 **"모달을 추가할 수 없습니다"**라는 메시지를 console에 출력합니다.
- close: 특정 key를 가진 모달을 닫습니다.
- clear: 모든 모달을 닫습니다.
- useModal 훅
- useModal은 openModal 와 closeModal 메서드를 제공하여 모달을 열고 닫는 기능을 제공합니다.
- openModal은 새로운 모달을 열기 위해 사용됩니다. 모달에 전달할 컴포넌트(Component)와 프로퍼티(props)를 받습니다. stack: false로 설정된 경우, 이미 모달이 존재하면 새 모달을 추가하지 않습니다.
- closeModal은 특정 key를 사용해 모달을 닫습니다. 상태에서 key를 가진 모달을 제거합니다.
- ModalContainer 컴포넌트
- ModalContainer는 Zustand의 modals 상태를 기반으로 실제 모달을 화면에 렌더링합니다.
- Zustand의 useModalStore에서 modals 배열과 close 메서드를 가져옵니다.
- useEffect로 모달을 렌더링할 DOM 요소(portal)를 초기화합니다.
- modals 배열을 순회하며, 각 모달의 Component를 createPortal을 이용해 portal에 렌더링합니다.
- 모달 닫기와 확인 버튼 처리를 위해 각각 handleClose와 handleSubmit을 정의합니다.
- handleClose: onClose 실행 후 모달 닫기.
- handleSubmit: onSubmit 실행 후 모달 닫기.
1. 먼저, modalStore를 작성합니다.
modalStore는 모달이 가진 공통 속성들과 현재 화면에 띄워진 모달 전체를 관리하는 데에 사용됩니다. 사용자 요구 사항은 다음과 같습니다.
- 쌓을 수 있는 모달의 경우, 이미 띄워진 모달이 있으면 그 위에 새로운 모달을 띄운다. 새로운 모달을 닫으면, 기존의 모달이 보여야 한다.
- 쌓을 수 없는 모달(ex:alert)의 경우, 이미 띄워진 모달이 있으면 그 모달을 닫을 때까지 새로운 모달을 띄울 수 없다. 또한, 배경이 오퍼시티가 적용된 반투명한 검은색으로 깔려 모달(alert) 이외의 영역을 클릭할 수 없어야 한다. 반드시 한 번에 하나의 모달만 띄울 수 있다.
이에 따라 모달 컴포넌트와 모달 스토어를 정리해보면, 다음과 같습니다.
모달 컴포넌트는 다음과 같은 필수 props를 필요로 합니다.
- key : 모달의 고유 키
- Component : 렌더링 될 모달 컴포넌트
- props : 렌더링 될 모달 컴포넌트에 전송될, 기타 props
모달의 상태를 관리하는 스토어는 다음과 같은 필수 props를 필요로 합니다.
- onClose // 모달을 닫는 함수
- onSubmit // 모달 내에 확인 등 close 이외의 버튼이 존재할 경우를 대비한 콜백 함수
- modalId // 모달 고유 식별자
- [property:string] : any // ...rest를 받아오기 위한 타입
모달 스토어의 역할은 다음과 같습니다.
- 사용자의 요구사항에 맞추어 '모달을 여러개 쌓을 수 있느냐, 하나만 쌓을 수 있느냐' 를 판별하기 위해 stack이라는 변수를 사용합니다.
- stack이 true일 경우, 모달을 리스트 형태로 저장하여 여러 모달을 쌓을 수 있게 합니다.
- stack이 false일 경우, 리스트에 단 하나의 모달만 들어갈 수 있게 합니다.
- 사용자가 모달을 열 수 있게 하기 위하여, 리스트에 모달을 저장하는 함수를 제공합니다.
- stack이 false일 경우, 리스트 내에 이미 모달이 존재할 시 에러 콘솔을 출력합니다.
- stack이 false이면서 리스트 내에 할당된 모달이 없으면, 리스트에 모달을 하나 넣습니다.
- 사용자가 모달을 닫을 수 있게 하기 위하여, 리스트에서 모달을 제거하는 함수를 제공합니다.
- 주어진 식별자 modalId와 같은 아이템을 찾아 리스트에서 제거합니다.
- 모든 모달을 닫기 위하여, 모달 리스트를 공배열로 초기화하는 함수를 제공합니다.
import { ComponentType } from "react"; // React에서 제공하는 타입, 컴포넌트 타입 정의에 사용
import { create } from "zustand"; // Zustand 라이브러리에서 `create` 함수 가져오기
// 모달 컴포넌트에 전달할 프로퍼티 타입 정의
export type ModalPropsType = {
onClose?: () => void; // 모달 닫기 콜백 함수 (선택적)
onSubmit?: (event?: any) => void; // 모달 확인 버튼 클릭 콜백 함수 (선택적)
modalId?: string; // 모달 고유 식별자 (내부에서 관리)
[property: string]: any; // 추가적인 속성을 허용
};
// 모달의 데이터 타입 정의
type ModalType = {
key: string; // 모달의 고유 키 (중복 방지)
props: ModalPropsType; // 모달에 전달되는 프로퍼티
Component: ComponentType<ModalPropsType>; // 렌더링될 React 컴포넌트
};
// 모달 스토어의 상태와 메서드 타입 정의
type ModalStoreType = {
modals: ModalType[]; // 현재 열려 있는 모달들의 배열
open: (Component: ComponentType<any>, props: ModalPropsType) => void; // 모달 열기 메서드
close: (key: string) => void; // 특정 모달 닫기 메서드
clear: () => void; // 모든 모달 닫기 메서드
};
// Zustand를 사용하여 모달 스토어 생성
export const useModalStore = create<ModalStoreType>((set, get) => ({
modals: [], // 초기 상태: 모달 리스트는 빈 배열
open: (Component: ComponentType<any>, props: ModalPropsType) => {
const { modals } = get(); // 현재 열려 있는 모달 리스트 가져오기
const modal = props.key ? modals.find((m) => m.key === props.key) : null;
// key가 제공되었을 경우, 동일한 key를 가진 모달이 이미 열려 있는지 확인
const key = props.key || Date.now().toString(); // key가 없으면 현재 시간을 문자열로 사용
props.modalId = key; // 모달 ID를 props에 저장
// stack: false인 경우
if (props.stack === false) {
if (modals.length > 0) {
console.error(
"새로운 모달을 열 수 없습니다. 기존 모달이 닫혀야 합니다."
);
return;
}
set({ modals: [{ Component, props, key }] }); // 기존 모달을 덮어씀
return;
}
// stack: true 경우
if (!modal) {
set({ modals: [...modals, { Component, props, key }] });
}
},
close: (key: string) => {
const { modals } = get(); // 현재 열려 있는 모달 리스트 가져오기
set({ modals: modals.filter((m) => m.key !== key) });
// 주어진 key와 일치하지 않는 모달들만 남김 (특정 모달 닫기)
},
clear: () => {
set({ modals: [] }); // 모든 모달을 닫음 (리스트 초기화)
},
}));
// 모달 열기 함수만 선택적으로 사용하는 훅
export const useModalStoreOpen = () => useModalStore((state) => state.open);
// 모달 닫기 함수만 선택적으로 사용하는 훅
export const useModalStoreClose = () => useModalStore((state) => state.close);
// 모든 모달 초기화 함수만 선택적으로 사용하는 훅
export const useModalStoreClear = () => useModalStore((state) => state.clear);
2. 모달 스토어를 활용한 useModal hook을 작성합니다.
사용자의 주 활동은 '모달 열기, 모달 닫기' 로 유추되므로 open과 close 함수를 제공해 주는것이 핵심입니다. 이 때, 'stack' 이라는 값이 주어지지 않으면 true로 할당되도록 하여 undefined 에러가 나는 것을 방지합니다.
import { ComponentType } from "react";
import {
ModalPropsType,
useModalStoreClose,
useModalStoreOpen,
} from "../stores/modalStore";
export default function useModal() {
const open = useModalStoreOpen();
const close = useModalStoreClose();
const openModal = <P extends ModalPropsType>(
Component: ComponentType<P>,
props?: P & { stack?: boolean }
) => {
open(Component, { ...props, stack: props?.stack ?? true });
};
const closeModal = (key: string) => {
close(key);
};
return { openModal, closeModal };
}
3. 실제로 모달을 화면에 렌더링하는 ModalContainer를 작성하고, index.html에 모달이 렌더링 될 자리를 만듭니다.
모든 modal은 ModalContainer를 통해 렌더링됩니다. createPortal를 통해 div의 id가 modal인 곳을 타겟으로 모달을 렌더링 할 것이므로, index.html에 <div id="modal"></div>를 추가합니다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>crmSoft</title>
</head>
<body>
<body>
<div id="root"></div>
<div id="modal"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</body>
</html>
ModalContainer를 작성합니다. createPortal를 통해 컴포넌트와 컴포넌트가 생성될 위치를 설정하면, 해당 위치에 지정한 컴포넌트를 마운트 할 수 있습니다. 이 때, ModalContainer이 마운트 되고 난 뒤 모달이 생성될 portal DOM을 할당하기 위하여 useEffect를 사용하였습니다.
또한 현재 모달의 stack여부를 판단하여 사용자가 요구한 아래 사항을 충족시키기 위해 isStackFalse 변수를 만들었습니다.
- * 쌓을 수 없는 모달(ex:alert)의 경우, 이미 띄워진 모달이 있으면 그 모달을 닫을 때까지 새로운 모달을 띄울 수 없다. 또한, 배경이 오퍼시티가 적용된 반투명한 검은색으로 깔려 모달(alert) 이외의 영역을 클릭할 수 없어야 한다. 반드시 한 번에 하나의 모달만 띄울 수 있다.
modals.length === 1: 현재 modals 배열에 모달이 정확히 1개 존재하는지 확인합니다.
modals[0].props.stack === false: 그 첫 번째 모달(modals[0])의 props에서 stack 값이 false인지를 확인합니다.
이 두 조건이 모두 참일 경우, isStackFalse는 true가 되고, 그렇지 않으면 false가 됩니다.
따라서 isStackFalse는 "현재 열려 있는 모달이 하나이고, 그 모달이 stack: false로 설정된 경우" 를 확인하는 조건입니다
이 조건을 통해 stack: false로 설정된 모달이 하나만 열려 있을 때, 새로운 모달을 열지 않도록 제어하는 용도로 사용됩니다.
isStackFalse이 참일 때, '모달의 배경에 반투명한 검은색의 클릭할 수 없는 영역' 을 설정하기 위하여 tailwind를 사용한 div를 사용해 주었습니다.
import { ModalPropsType, useModalStore } from "../../stores/modalStore";
import { Nullable } from "../../types";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
export default function ModalContainer() {
// 현재 모달 리스트 및 모달 닫기 메서드
const { modals, close } = useModalStore();
// 모달을 렌더링할 DOM 요소
const [portal, setPortal] = useState<Nullable<Element>>(null);
// 컴포넌트가 마운트되었을 때 모달을 렌더링할 DOM 요소를 설정
useEffect(() => {
setPortal(document.getElementById("modal"));
}, []);
const isStackFalse = modals.length === 1 && modals[0].props.stack === false;
return (
portal &&
createPortal(
<>
{isStackFalse && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 pointer-events-auto" />
)}
{/* 외부 클릭 차단 */}
{modals.map((m) => {
const { Component, props, key } = m;
const {
onSubmit = () => {},
onClose = () => {},
...restProps
} = props;
const handleClose = async () => {
await onClose?.();
close(key);
};
const handleSubmit = async (_props?: ModalPropsType) => {
await onSubmit?.(_props);
close(key);
};
return (
<Component
key={key}
modalId={props.modalId}
onSubmit={handleSubmit}
onClose={handleClose}
{...restProps}
/>
);
})}
</>,
portal
)
);
}
4. App에 작성한 ModalContainer 컴포넌트를 넣습니다.
function App() {
return (
<ThemeProvider>
<RouterProvider router={router} />
<ModalContainer />
</ThemeProvider>
);
}
useModal 사용 예시
stack을 true로 주면 여러개 쌓을 수 있는 모달을, false로 주면 단 하나의 모달만 띄우도록 설정할 수 있습니다.
이를 통해, 쌓이는 모달과 하나만 가능한 커스텀 Alert를 이용할 수 있게 되었습니다.
// BasicAlert은 직접 만든 컴포넌트
const AlertComponent = ({
onClose,
onSubmit,
modalId,
}: {
onClose: () => void;
onSubmit: () => void;
modalId: string;
}) => {
return (
<BasicAlert>
<BasicAlert.Title>{modalId}</BasicAlert.Title>
<BasicAlert.ButtonZone>
<BasicButton onClick={onClose} variant="outlined">
취소
</BasicButton>
<BasicButton onClick={onSubmit} variant="contained">
확인
</BasicButton>
</BasicAlert.ButtonZone>
</BasicAlert>
);
};
export default function App() {
const { openModal, closeModal } = useModal();
const handleOpenStackedModal = () => {
openModal(AlertComponent, {
modalId: "alert1",
stack: false, // 단일 모달 모드, 쌓기 식으로 하려면 true
onClose: () => console.log("모달 닫힘"),
onSubmit: () => console.log("확인"),
});
};
return(
<div>
<button onClick={handleOpenStackedModal}
</div>
);
}
- 여러개 쌓을 수 있는 경우 (stack:true)
- 하나만 사용 가능한 경우 (stack:false)
'프로젝트 > React 프론트 프로젝트' 카테고리의 다른 글
[React] Zustand의 persist (1) | 2024.11.27 |
---|---|
[React] 값이 없는 상태를 어떻게 관리해야 할까? undefined와 null (1) | 2024.11.27 |
[React] 보일러 프로젝트 - 프론트 완성 , + (01.20 회원가입 백엔드까지 완성) (0) | 2023.01.19 |
[React] My Portfolio React 프로젝트 (1) | 2023.01.14 |
[React] React SPA - Slide Template + 반응형 Nav menu (0) | 2023.01.11 |