
현재 개발 중인 웹사이트에서 모달이 여러 페이지에 존재한다.



따라서 모달을 열고 닫는 함수와 모달이 열려 있는지의 상태를 전역으로 관리해야 했다.
그동안 모달 전역 상태는 React의 Context API를 통해서만 정의해 봤어서, 이번에는 zustand를 이용해서 모달 전역 상태를 만들어보기로 했다.
여러 컴포넌트에서 모달을 열거나 닫아야 할 때
openModal()과 closeModal()을 어디서든 호출할 수 있음모달이 어디서든 같은 방식으로 작동해야 할 때 (일관성 유지)
useState로 따로 관리해야 해서 중복이 많아지고 비일관적인 행동이 발생할 수 있음라우팅이 바뀌어도 모달을 유지하거나 제어할 때
/product/1에서 모달을 띄운 상태로 /product/2로 이동할 때도 모달을 유지하고 싶다면 로컬 상태는 불가능여러 개의 모달이 겹치는 경우
상태 디버깅 및 테스트가 쉬워짐
➡️ 모달 상태는 여러 곳에서 접근하고 조작할 필요가 있을 때 전역으로 관리하는 것이 유리함
modal.store.ts : 전역 상태 정의import { create } from "zustand";
interface ModalState {
isModalOpen: boolean; // 모달이 열려 있는지 여부
modalContent: React.ReactNode | null; // 모달 안에 보여줄 React 컴포넌트(JSX)
openModal: (element: React.ReactNode) => void; // 특정 JSX 요소를 넣어 모달 열기
closeModal: () => void; // 모달 닫기
}
export const useModalStore = create<ModalState>()((set) => ({
isModalOpen: false,
modalContent: null,
openModal: (element: React.ReactNode) => set({ isModalOpen: true, modalContent: element }),
closeModal: () => set({ isModalOpen: false, modalContent: null }),
}));
목적: 모달을 전역에서 열고 닫을 수 있도록 하기 위한 Zustand 상태 정의
코드 설명
useModalStore 훅을 export 해서 앱 전체에서 사용할 수 있게 함openModal()을 호출하면isModalOpen이 true가 되고,modalContent에 넘긴 컴포넌트가 저장됨closeModal()을 호출하면isModalOpen이 false가 되어서 모달을 닫고,modalContent도 null로 초기화됨왜 React.ReactElement가 아니라 React.ReactNode인가?
💖
ReactNode란?
- React가 JSX 내부에서 렌더링할 수 있는 모든 타입을 통칭하는 타입
- 즉, 다음처럼
return구문이나 JSX 안에 들어갈 수 있는 거의 모든 걸 포함하는 타입임return <div>Hello</div>; // 가능 return "Hello"; // 가능 return 123; // 가능 return null; // 가능 return [<A />, <B />, "C"]; // 가능 return false; // 가능 (렌더링 안 됨)
💖
ReactNode타입 구조type ReactNode = | ReactElement | string | number | boolean | null | undefined | ReactNode[]
타입 설명 예시 ReactElementJSX로 만든 컴포넌트나 태그 <div>hi</div>,<CityModal />string문자열 "hello",'텍스트'number숫자 123,3.14booleantrue/false 값 false는 렌더링 안 됨null아무것도 렌더링하지 않음 nullundefined아무것도 렌더링하지 않음 undefinedReactNOde[]위 값들을 배열로 여러 개 받음 [<A />, "text", 42, null]
💖 왜
boolean,null,undefined도 포함될까?
- React는 이런 값들도 JSX 안에 있을 수 있도록 허용하지만, 실제 렌더링은 하지 않음
- 그래서 렌더링은 안 되지만 JSX 문법상 올바른 값이라서 타입에 포함됨
💖 핵심 비교:
React.ReactElementvs.React.ReactNode
타입 의미 예시 제한 여부 React.ReactElementJSX로 생성된 단일 요소만 허용 <CityModal />단일 요소만 허용 React.ReactNodeElement + 그 외 모든 렌더링 가능한 값들 <div>hi</div>,"문자",[<div />, <span />],null등매우 유연
💖 실제로 모달에서 가능한 사용 예시들
openModal(<CityModal />); // ReactElement openModal(<><CityModal /><AnotherModal /></>); // Fragment → ReactNode openModal("로딩 중입니다..."); // string → ReactNode openModal(null); // null → ReactNode openModal(`<p>경고 메시지</p>`) // ReactElement위에서
React.ReactElement를 타입으로 정하면 빨간 줄이 생기는 경우const openModal = (element: React.ReactElement) => ...
- 이럴 경우
"로딩 중입니다...",<><Modal1 /><Modal2 /></>같은 JSX의 다른 타입들은 넣을 수 없음
💖 결론: 왜
modalContent: React.ReactNode | null인가?
- 다양한 형태의 모달 콘텐츠 허용
<CityModal />처럼 컴포넌트뿐만 아니라"로딩 중...",null,<><A /><B /></>같은 다양한 형태를 지원하려면ReactNode여야 함- 실제로 React의
children타입도ReactNode
- 리액트 컴포넌트의
children도ReactNode타입으로 되어 있음 → 유연하고 일관된 패턴- 사용자 정의 모달 콘텐츠 확장성
- 팀원이 나중에
openModal("안내 메시지"),openModal(<ErrorComponent />)처럼 쓸 수 있게 하려면ReactNode로 정의해두는 게 나음
ModalBackground.tsx : 모달 시각적 렌더링import { useModalStore } from "../../stores/modal.store";
const ModalBackground = () => {
const { isModalOpen, modalContent, closeModal } = useModalStore();
if (!isModalOpen) return null;
return (
<div onClick={closeModal} className="fixed inset-0 z-50 flex justify-center items-center bg-black/50">
{modalContent}
</div>
);
};
export default ModalBackground;
목적: 화면 위에 오버레이된 배경과 함께 modalContent를 보여주는 컴포넌트
코드 설명
bg-black/50을 통해 배경화면을 투명도 50%의 어두운 배경으로 설정함modalContent는 openModal()로 설정한 컴포넌트롤 보여줌Layout.tsx : ModalBackground 컴포넌트 렌더링const Layout = () => {
// 생략
return (
<>
<ModalBackground />
{/* 생략 */}
</>
);
};
목적: 모달이 모든 페이지 위에 항상 떠 있어야 하므로, 모든 라우트에서 공유되는 Layout에 포함시킴
코드 해석
openModal()을 호출해도 실제로 모달이 안 뜸핵심 포인트
Layout은 보통 앱의 전체적인 틀(Header, Footer, main)을 담고 있어 전역 요소 넣기에 적절함ModalBackground를 여기에 두면, 어떤 페이지든 전역 상태에 따라 모달이 뜰 수 있음CityCard.tsx : 모달 여는 함수 사용import { useModalStore } from "../../stores/modal.store";
import CityModal from "../modal/CityModal";
interface CityCardProps {
cardImg: string;
cardName: string;
}
const CityCard = ({ cardImg, cardName }: CityCardProps) => {
const { openModal } = useModalStore();
const handleOpenModal = () => {
openModal(<CityModal cardName={cardName} cardImg={cardImg} />);
};
return (
<div
onClick={handleOpenModal}
className="flex flex-col gap-[3px] p-[10px] bg-white w-[303.5px] h-fit rounded-[8px] [box-shadow:4px_8px_4px_rgba(0,0,0,0.1)] hover:cursor-pointer duration-300 hover:scale-108 overflow-hidden">
<img
src={cardImg}
alt={cardName}
className="rounded-[4px] h-[200px] object-cover text-[12px]"
/>
<p>{cardName}</p>
</div>
);
};
export default CityCard;
목적: 카드를 클릭하면 CityModal을 전역 모달로 띄우기 위함
코드 설명
모달 열기 함수(openModal) 가져오기
카드 클릭 시 CityModal 컴포넌트를 모달로 띄움
이걸 상태로 넘겨줌으로써 ModalBackground가 해당 JSX를 보여줌
💖 여기서 일어나는 일
<CityModal ... />JSX는 실제로 실행되면ReactElement가 됨- 이 JSX 컴포넌트를
modalContent라는 상태값에 저장함set({ isModalOpen: true, modalContent: element });- 이
modalContent상태를ModalBackground에서 꺼내서 화면에 렌더링함return <div>{modalContent}</div>;
💖 다시 정리하면
- "상태로 넘긴다"는 말은 React 컴포넌트(JSX)를 전역 상태(
modalContent)에 저장한다는 뜻- 이 저장된 컴포넌트는
ModalBackground가useModalStore()를 통해 가져와서 렌더링하는 대상이 됨- 그래서
CityModal이 뜨는 것처럼 보임
CityModal.tsx : 실제로 보여지는 모달 콘텐츠, 모달 닫기 함수 사용import { useModalStore } from "../../stores/modal.store";
import { useNavigate } from "react-router";
interface CityModalProps {
cardName: string;
cardImg: string;
}
const CityModal = ({ cardName, cardImg }: CityModalProps) => {
const { closeModal } = useModalStore();
const navigate = useNavigate();
// 생략
const handleDeciedTravelArea = () => {
closeModal();
navigate(`/place-exploration/${cardName}`);
};
return (
<div onClick={(e) => e.stopPropagation()}>
<button onClick={closeModal}>
<img src="/images/close.svg" alt="close" />
</button>
<div>
<h1>{cardName}</h1>
<CityBasicInfo cardName={cardName} />
<CityInfo cardName={cardName} />
</div>
<div>
<img src={cardImg} alt={cardName} />
<div>
{/* 생략 */}
<button onClick={handleDecideTravelArea}>
여행 지역 정하기
</button>
</div>
</div>
</div>
);
};
export default CityModal;
목적: 도시 정보를 보여주고, 숙소/항공권 링크 및 이동 버튼 제공
코드 설명
e.stopPropagation() : 배경 클릭 시 모달이 닫히는데, 모달 내부 클릭은 닫히지 않게 막아줌