[React] Zustand를 이용하여 useModal 훅 만들기

반응형

 

 

 

외주 프로젝트 중 여러 알림을 쌓으면서 관리해야 하는 Alert, 한 번에 하나만 띄워져야 하는 Modal을 useModal이라는 하나의 훅을 이용해 관리하기 위해 작성해 보았다.

 

코드의 흐름 설명

 

  1. 모달 상태 관리(useModalStore)
    • useModalStore는 Zustand를 사용해 모달의 상태를 관리하며, stack 옵션에 따라 모달의 관리 방식을 조정합니다.
    • modals: 현재 열려 있는 모달의 배열입니다.
    • open: 특정 컴포넌트를 모달로 열고, 관련 데이터를 상태에 추가합니다.
      • stack: true: modals 배열에 새로운 모달을 추가합니다.
      • stack: false: 기존 모달이 없을 때만 추가. 기존 모달이 있다면 **"모달을 추가할 수 없습니다"**라는 메시지를 console에 출력합니다.
    • close: 특정 key를 가진 모달을 닫습니다.
    • clear: 모든 모달을 닫습니다.
  2. useModal 훅
    • useModal은 openModal 와 closeModal  메서드를 제공하여 모달을 열고 닫는 기능을 제공합니다.
    • openModal은 새로운 모달을 열기 위해 사용됩니다. 모달에 전달할 컴포넌트(Component)와 프로퍼티(props)를 받습니다. stack: false로 설정된 경우, 이미 모달이 존재하면 새 모달을 추가하지 않습니다.
    • closeModal은 특정 key를 사용해 모달을 닫습니다. 상태에서 key를 가진 모달을 제거합니다.
  3. 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이라는 변수를 사용합니다.
    • stacktrue일 경우, 모달을 리스트 형태로 저장하여 여러 모달을 쌓을 수 있게 합니다.
    • stackfalse일 경우, 리스트에 단 하나의 모달만 들어갈 수 있게 합니다.
  • 사용자가 모달을 열 수 있게 하기 위하여, 리스트에 모달을 저장하는 함수를 제공합니다.
    • 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)

 

반응형