팀원분이 만들어주신 zustand를 사용한 모달 공용 컴포넌트가 React Portal 과 충돌되는 문제가 발생해 새롭게 공용 모달 컴포넌트를 구현해야했음
장점
단점
장점
단점
"use client";
import CloseBtn from "@/components/icons/modal/CloseBtn";
import ModalBtn from "@/components/modal/ModalBtn";
import React, { useCallback, useEffect, useState } from "react";
import ReactModal from "react-modal";
type ButtonConfig = {
text: string;
style: string;
};
type ModalConfig = {
message: string;
confirmButton: ButtonConfig;
cancelButton?: ButtonConfig;
};
const useModal = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [config, setConfig] = useState<ModalConfig>({
message: "",
confirmButton: { text: "", style: "" },
cancelButton: undefined
});
const [onConfirm, setOnConfirm] = useState<(() => void) | null>(null);
const openModal = useCallback((newConfig: ModalConfig, confirmCallback?: () => void) => {
setConfig(newConfig);
setIsModalOpen(true);
setOnConfirm(() => confirmCallback || null);
}, []);
const closeModal = useCallback(() => {
setIsModalOpen(false);
setOnConfirm(null);
}, []);
const handleConfirm = useCallback(() => {
if (onConfirm) {
onConfirm();
}
closeModal();
}, [onConfirm, closeModal]);
const handleCancel = useCallback(() => {
closeModal();
}, [closeModal]);
const getButtonStyle = (style: string) => {
switch (style) {
case "확인":
return "bg-system-red200 text-system-white";
case "취소":
return "bg-system-white border border-solid border-gray-400 text-system-black";
case "삭제":
return "bg-system-red200 text-system-white";
case "시스템":
return "bg-gradient-pai400-fai500-br text-system-white hover:border-paiTrans-60032 active:bg-gradient-pai600-fai700-br";
default:
return style;
}
};
const Modal = () => {
console.log();
return (
<ReactModal
isOpen={isModalOpen}
onRequestClose={handleCancel}
className="text-center bg-whiteTrans-wh72 mobile:w-[calc(100%-32px)] mx-auto rounded-[32px] p-6 desktop:w-[343px] outline-none"
overlayClassName="fixed inset-0 bg-modalBg-black40 backdrop-blur-md z-[10000] flex items-center justify-center"
ariaHideApp={false}
shouldCloseOnEsc={true}
shouldCloseOnOverlayClick={true}
>
<div className="mb-5 relative">
<CloseBtn btnStyle={"absolute right-0 top-[-6px] cursor-pointer"} onClick={handleCancel} />
<div className="flex flex-col min-h-16 items-center justify-center">
{config.message.split("\n").map((line, index) => (
<React.Fragment key={index}>
<span className="flex items-center justify-center font-medium text-gray-900 text-base leading-[27px]">
{line}
</span>
</React.Fragment>
))}
</div>
</div>
<div className="flex justify-center gap-2">
{config.cancelButton && (
<ModalBtn
className={getButtonStyle(config.cancelButton.style)}
onClick={handleCancel}
text={config.cancelButton.text}
/>
)}
<ModalBtn
className={getButtonStyle(config.confirmButton.style)}
onClick={handleConfirm}
text={config.confirmButton.text}
/>
</div>
</ReactModal>
);
};
return {
isModalOpen,
openModal,
closeModal,
Modal
};
};
export default useModal;
- 모달 열기 → openModal 호출 → onConfirm 상태에 함수 저장
- 사용자 확인 버튼 클릭 → handleConfirm 호출 → 저장된 onConfirm 함수 실행 → 페이지 이동 또는 다른 동작 수행
setOnConfirm(() => confirmCallback || null)
로 onConfirm 상태를 설정() => confirmCallback || null
은 함수를 반환하는 함수. 이는 confirmCallback을 즉시 실행하지 않고, 나중에 실행할 수 있도록 저장if (onConfirm) { onConfirm(); }
를 실행() => confirmCallback || null
로 wrapping함으로써, confirmCallback의 실행을 지연❗️모달에서 확인 버튼을 누를 시, 다른 페이지로 이동해야하는데 모달을 열자마다 다른 페이지로 이동하는 오류 발생
onComfirm에 함수가 아니라 함수값을 저장 시 문제
confirmCallback
함수 자체가 아니라 그 함수의 실행 결과가 저장confirmCallback
이 실행되어 즉시 페이지 이동이 일어남const openModal = useCallback((newConfig: ModalConfig, confirmCallback?: () => void) => {
setConfig(newConfig);
setIsModalOpen(true);
setResult(null);
setOnConfirm(confirmCallback || null); // 함수값을 저장
}, []);
openModal 실행 시, setOnConfirm에 함수 자체를 넣어주어 confirmCallback함수가 실행될 수 있도록 변경
const openModal = useCallback((newConfig: ModalConfig, confirmCallback?: () => void) => {
setConfig(newConfig);
setIsModalOpen(true);
setOnConfirm(() => confirmCallback || null); // 함수 자체를 저장
}, []);
const { openModal, Modal } = useModal();
openModal(
// 첫번째 인자는 모달창 스타일과 관련된 내용
{
message: /* 모달창에 들어갈 텍스트 string으로 넣기 */,
confirmButton: { text: /* 버튼에 들어갈 텍스트 string으로 넣기*/, style: /* useModal 훅에서 getButtonStyle 함수 switch문 case에 있는 텍스트 확인 후 맞는 케이스 넣기*/}
// cancelButton 필요 시 넣기 (현재는 회원탈퇴 페이지에서만 쓰임)
},
// 확인 버튼 클릭 시 실행될 함수 두번째 인자로 넣기
() => {
router.push("/login");
}
);
필수 인자인 message, confirmButton만 넣어 사용
"use client";
import { AIType } from "@/types/chat.session.type";
import SessionBtn from "./_components/SessionBtn";
import { Metadata } from "next";
import useModal from "@/hooks/useModal";
import { useRouter } from "next/navigation";
const metadata: Metadata = {
title: "PAi 채팅 페이지",
description: "PAi/FAi 채팅 페이지입니다.",
keywords: ["chat", "assistant", "friend"],
openGraph: {
title: "채팅 페이지",
description: "PAi/FAi 채팅 페이지입니다.",
type: "website"
}
};
const aiTypes: AIType[] = ["assistant", "friend"];
const ChatPage = () => {
// TODO : 여기서 리스트 불러오면 prefetch 사용해서 렌더링 줄이기
const { openModal, Modal } = useModal();
const router = useRouter();
const handleUnauthorized = () => {
openModal(
{
message: "로그인 이후 사용가능한 서비스입니다.\\n로그인페이지로 이동하시겠습니까?",
confirmButton: { text: "확인", style: "시스템" }
},
() => router.push("/login")
);
};
return (
<>
// 모달창 띄우는 컴포넌트에 모달 컴포넌트 넣기
// 하단이든 상단이는 위치는 상관없지만 상단에 두었을 때 모달이 쓰인다는 것을 직관적으로 알 수 있어 상단에 위치시키는 것을 더 권장
<Modal />
<div className="gradient-container w-full h-full rounded-t-[60px]">
<div className="gradient-rotated gradient-ellipse w-full h-[90%]"></div>
<div className="relative z-10 w-full h-full">
<div className="flex flex-col items-center justify-center w-full h-full">
<span className="text-gray-600 font-medium text-lg">어떤 파이와 이야기해 볼까요?</span>
<div className="flex flex-col p-4 gap-6">
{aiTypes.map((aiType) => (
<SessionBtn key={aiType} aiType={aiType} handleUnauthorized={handleUnauthorized} />
))}
</div>
</div>
</div>
</div>
</>
);
};
export default ChatPage;
3번째 인자로 cancelButton 추가하기
const handleClickDelete = () => {
if (!isAgreement) {
toast.warn("회원 탈퇴 유의사항에 동의해주세요.");
return;
}
openModal(
{
message: "정말 탈퇴하시겠어요?",
confirmButton: { text: "확인", style: "확인" },
cancelButton: { text: "취소", style: "취소" }
},
handleDeleteAccount
);
};
Custom Hook 방식 채택으로 필요한 컴포넌트에서만 모달 상태 관리하여 불필요한 리렌더링 줄이고 성능 최적화, 코드 재사용성 및 유지보수 용이성 향상