[TIL-0511] Zustand로 모달 전역 상태 정의하기

jiny·2025년 5월 20일

캡스톤2

목록 보기
9/22
post-thumbnail

🌟 모달 전역 상태 정의

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

  • 메인 페이지: 도시(지역) 카드 모달
  • 장소 탐색 페이지 & 여행 계획 페이지: 장소 카드 모달
  • 마이페이지: 여행 계획 카드 모달

따라서 모달을 열고 닫는 함수와 모달이 열려 있는지의 상태를 전역으로 관리해야 했다.
그동안 모달 전역 상태는 React의 Context API를 통해서만 정의해 봤어서, 이번에는 zustand를 이용해서 모달 전역 상태를 만들어보기로 했다.


🌟 모달 상태를 전역 상태로 정의해야 하는 상황

  1. 여러 컴포넌트에서 모달을 열거나 닫아야 할 때

    • 예: 모달을 여는 버튼이 헤더에 있고, 모달 안의 닫기 버튼은 모달 컴포넌트 안에 있을 경우
    • 이때, 모달을 여는 쪽과 닫는 쪽이 서로 다른 컴포넌트에 있기 때문에 상태가 로컬이면 공유할 수 없음
    • 전역 상태를 쓰면 openModal()과 closeModal()을 어디서든 호출할 수 있음
  2. 모달이 어디서든 같은 방식으로 작동해야 할 때 (일관성 유지)

    • 예: 여러 페이지에서 "로그인 모달"이 필요할 경우
    • 로컬 상태를 쓰면 각 페이지마다 useState로 따로 관리해야 해서 중복이 많아지고 비일관적인 행동이 발생할 수 있음
    • 전역으로 하면 로그인 모달이 어디서든 동일하게 작동하고, 상태 공유가 쉬움
  3. 라우팅이 바뀌어도 모달을 유지하거나 제어할 때

    • 예: /product/1에서 모달을 띄운 상태로 /product/2로 이동할 때도 모달을 유지하고 싶다면 로컬 상태는 불가능
    • 전역 상태로 하면 페이지 간 상태 유지가 쉬움
    • 이는 특히 SPA(Single Page Application)에서 유용함
  4. 여러 개의 모달이 겹치는 경우

    • 예: 설정 모달 → 하위 설정 모달 → 확인 모달 등 중첩된 모달이 있을 경우
    • 전역 상태로 각 모달을 제어하면 트리 구조 없이도 쉽게 상태 관리 가능
  5. 상태 디버깅 및 테스트가 쉬워짐

    • 전역 상태는 디버깅 도구(Zustand Devtools, Redux Devtools 등)와 통합되어 상태 흐름을 추적하기 쉬움
    • 모달 상태의 열림/닫힘 여부를 시간 순으로 확인할 수 있음

➡️ 모달 상태는 여러 곳에서 접근하고 조작할 필요가 있을 때 전역으로 관리하는 것이 유리함

  • UI 일관성, 코드 재사용성, 유지보수성, 디버깅 편의성에서 이점이 많음

🌟 모달 상태를 로컬 상태로 정의해도 되는 경우

  1. 모달이 한 컴포넌트 내부에서만 쓰이는 경우
  2. 외부에서 모달을 열거나 닫을 일이 없는 경우
  3. 재사용성이 낮은 경우

🌟 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.14
    booleantrue/false 값false는 렌더링 안 됨
    null아무것도 렌더링하지 않음null
    undefined아무것도 렌더링하지 않음undefined
    ReactNOde[]위 값들을 배열로 여러 개 받음[<A />, "text", 42, null]

    💖 왜 boolean, null, undefined도 포함될까?

    • React는 이런 값들도 JSX 안에 있을 수 있도록 허용하지만, 실제 렌더링은 하지 않음
    • 그래서 렌더링은 안 되지만 JSX 문법상 올바른 값이라서 타입에 포함됨

    💖 핵심 비교: React.ReactElement vs. 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인가?

    1. 다양한 형태의 모달 콘텐츠 허용
      • <CityModal />처럼 컴포넌트뿐만 아니라
      • "로딩 중...", null, <><A /><B /></> 같은 다양한 형태를 지원하려면 ReactNode여야 함
    2. 실제로 React의 children 타입도 ReactNode
      • 리액트 컴포넌트의 children도 ReactNode 타입으로 되어 있음 → 유연하고 일관된 패턴
    3. 사용자 정의 모달 콘텐츠 확장성
      • 팀원이 나중에 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를 보여줌

      💖 여기서 일어나는 일

      1. <CityModal ... /> JSX는 실제로 실행되면 ReactElement가 됨
      2. 이 JSX 컴포넌트를 modalContent라는 상태값에 저장함
        set({ isModalOpen: true, modalContent: element });
      3. 이 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() : 배경 클릭 시 모달이 닫히는데, 모달 내부 클릭은 닫히지 않게 막아줌
    • "여행 지역 정하기" 버튼을 누르면, 모달 닫고 해당 페이지로 이동함

0개의 댓글