본문 바로가기

프로젝트/dowith

Zustand를 활용한 모달 상태 관리

목표

- 모달은 프로젝트에서 다양한 화면과 상황에서 자주 사용되는 UI 요소인데, 프로젝트 규모가 커질수록 여러 곳에서 공통적으로 모달을 사용하게 되고, 각각의 모달 상태를 관리하는 코드는 점점 복잡해질 수 있다.

- 일반적으로 모든 모달마다 useState를 선언하고 상태를 관리해야 하기 때문에, 상태 관리 로직이 중복되고 반복되는 문제가 발생한다.

import { useState } from 'react';
import Modal from './Modal'; // 공통 모달 컴포넌트

const Example = () => {
  const [isFirstModalOpen, setIsFirstModalOpen] = useState(false);
  const [isSecondModalOpen, setIsSecondModalOpen] = useState(false);
  const [isThirdModalOpen, setIsThirdModalOpen] = useState(false);
  // useState의 다수 선언 및 중복로직

  return (
    <div>
      <button onClick={() => setIsFirstModalOpen(true)}>모달 1 열기</button>
      <button onClick={() => setIsSecondModalOpen(true)}>모달 2 열기</button>
      <button onClick={() => setIsThirdModalOpen(true)}>모달 3 열기</button>

      {isFirstModalOpen && (
        <Modal onClose={() => setIsFirstModalOpen(false)}>
          <p>첫 번째 모달</p>
        </Modal>
      )}

      {isSecondModalOpen && (
        <Modal onClose={() => setIsSecondModalOpen(false)}>
          <p>두 번째 모달</p>
        </Modal>
      )}

      {isThirdModalOpen && (
        <Modal onClose={() => setIsThirdModalOpen(false)}>
          <p>세 번째 모달</p>
        </Modal>
      )}
    </div>
  );
};

- 나는 프로젝트에서 상태 관리를 위해 Zustand를 사용하고 있으며, 이를 활용해 모달 구현 로직을 개선해보고자 한다.

- Zustand를 사용한 모달 상태 관리는 다음과 같은 장점이 있다.

  • 모든 모달의 상태를 한 곳에서 관리할 수 있다.
  • 상태 변경이 필요한 로직이 흩어지지 않으므로 유지보수성을 높일 수 있다.

실제 코드

// useModalStore.ts

import { create } from 'zustand';
import { TConfirmModalProps } from '@/components/common/modal/confirm-modal/confirm-modal';
import { TAlertModalProps } from '@/components/common/modal/alert-modal/alert-modal';
import { TUserSettingModalProps } from '@/components/modal/user-setting-modal/user-setting-modal';
import { TCreateSpaceModalProps } from '@/components/modal/create-space-modal/create-space-modal';

// 모달 타입 정의
type TModalType =
  | 'confirm' // 확인 모달
  | 'alert' // 알림 모달
  | 'form' // 폼 모달
  | 'user-setting' // 사용자 설정 모달
  | 'create-space'; // 스페이스 생성 모달

// 모달 구성 객체의 타입 정의
type TModalConfig = {
  type: TModalType; // 모달 타입
  id: string; // 모달의 고유 ID
  props?: // 모달에 전달될 추가 속성 (선택적)
    | TConfirmModalProps
    | TAlertModalProps
    | TUserSettingModalProps
    | TCreateSpaceModalProps;
};

// Zustand 상태 타입 정의
type TModalState = {
  modals: TModalConfig[]; // 현재 열려 있는 모달들의 배열
};

// Zustand로 모달 상태 관리 스토어 생성
export const useModalStore = create<TModalState>(() => ({
  modals: [], // 초기 상태로 빈 배열 설정
}));

// 모달 열기 함수
export const openModal = (modal: TModalConfig) => {
  useModalStore.setState((state) => {
    // 동일한 ID를 가진 모달이 이미 열려 있는지 확인
    const isModalAlreadyOpen = state.modals.some((m) => m.id === modal.id);
    if (isModalAlreadyOpen) {
      return state; // 동일 ID의 모달이 있으면 상태를 변경하지 않음
    }
    return { modals: [...state.modals, modal] }; // 새 모달 추가
  });
};

// 모달 닫기 함수
export const closeModal = (id: string) => {
  useModalStore.setState((state) => ({
    // ID가 일치하지 않는 모달만 유지 (해당 ID의 모달은 제거)
    modals: state.modals.filter((modal) => modal.id !== id),
  }));
};

- 모달 상태를 관리하는 store에서는 프로젝트에서 사용할 모달 타입과 모달을 구성하는 객체의 타입을 정의한다.

- store에는 현재 열려있는 모달들을 배열로 관리하며, 모달의 상태를 중앙에서 관리할 수 있도록 한다.

- 모달 열기 함수는 동일한 ID를 가진 모달이 존재하지 않을 때만 새로운 모달을 store에 추가한다.

- 모달 닫기 함수는 전달받은 ID와 동일한 모달을 제외한 나머지 모달만 open 상태로 유지한다.

// modal-manager.tsx

import { useModalStore, closeModal } from '@/store/use-modal-store'; 
// Zustand를 사용해 모달 상태를 관리하는 store와 모달 닫기 함수 가져옴.

import {
  ConfirmModal,
  TConfirmModalProps,
} from '@/components/common/modal/confirm-modal/confirm-modal';
// 'ConfirmModal' 컴포넌트와 해당 props 타입 가져옴.

import {
  AlertModal,
  TAlertModalProps,
} from '@/components/common/modal/alert-modal/alert-modal';
// 'AlertModal' 컴포넌트와 해당 props 타입 가져옴.
...

export const ModalManager = () => {
  const { modals } = useModalStore();
  // Zustand의 useModalStore에서 현재 열려 있는 모달 배열 가져옴.

  return (
    <>
      {modals.map((modal) => {
        const { id, type, props } = modal;
        // 모달 배열을 순회하며 각 모달의 id, type, props 추출.

        switch (type) {
          // 모달 타입에 따라 알맞은 컴포넌트를 렌더링.
          case 'confirm':
            return (
            // 각 모달을 구분하는 고유한 id 추가
              <div key={id}>
                <ConfirmModal
                  {...(props as TConfirmModalProps)}
                  onClose={() => closeModal(id)}
                />
              </div>
            );
          case 'alert':
            return (
              <div key={id}>
                <AlertModal
                  {...(props as TAlertModalProps)}
                  onClose={() => closeModal(id)}
                />
              </div>
            );
          ...
        }
      })}
    </>
  );
};
// Zustand로 관리하는 모달 배열을 기반으로, 각 모달 타입에 맞는 컴포넌트를 동적으로 렌더링.
// 모든 모달은 공통적으로 `onClose` 이벤트를 제공하여, closeModal 함수로 상태를 업데이트함.

- 가장 많이했던 고민은 alert, confirm과 같이 공통 컴포넌트로 만든 모달의 경우 상태가 변하는 경우 하나의 상태 변화가 모든 alert, confirm 모달 컴포넌트에 영향을 미친다는 것이었다.

- 즉, 모든 alert, confirm 모달을 각각 구분할 수 있는 고유한 식별자가 필요했고 모달의 type 뿐만 아니라 고유한 id를 추가하여 모달을 개별적으로 식별하고 관리할 수 있도록 처리했다.

// alert-modal.tsx 공통 모달 컴포넌트

export type TAlertModalProps = {
  title: string;
  description: string;
  confirmButtonBgColor?: 'red' | 'black';
  onConfirm: () => void;
  onClose?: () => void;
};

export const AlertModal = ({
  title,
  description,
  confirmButtonBgColor = 'red',
  onConfirm,
  onClose,
}: TAlertModalProps) => {
  return (
    <Dialog open={true} onOpenChange={onClose}>
      <ModalContent
        onInteractOutside={(e) => {
          e.preventDefault();
        }}
        hasCloseButton={false}
      >
        <ModalHeader>
          <ModalTitle>{title}</ModalTitle>
          <ModalDescription>{description}</ModalDescription>
        </ModalHeader>
        <DialogFooter>
          <Button
            bgColor={confirmButtonBgColor}
            onClick={() => {
              onConfirm();
              onClose && onClose();
            }}
          >
            확인
          </Button>
        </DialogFooter>
      </ModalContent>
    </Dialog>
  );
};
// use-auth-check-and-redirect-login.tsx
// 로그인 여부를 확인하고 비로그인 상태면 모달을 렌더링 하는 훅

import { useUserCode, useUserAppName } from '@/store/auth/use-user-store';
import { useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { openModal } from '@/store/use-modal-store';

export const useAuthCheckAndRedirectLogin = () => {
  const userCode = useUserCode();
  const userAppName = useUserAppName();
  const navigate = useNavigate();
  const [isCheckingAuth, setIsCheckingAuth] = useState(true);

  useEffect(() => {
    if (!userCode || !userAppName) {
      openModal({
        type: 'alert',
        id: 'need-login',
        props: {
          title: '로그인이 필요한 서비스입니다.',
          description: '"확인"을 누르면 로그인 화면으로 이동합니다.',
          onConfirm: () => {
            navigate('/');
          },
        },
      });
    } else {
      setIsCheckingAuth(false);
    }
  }, [userCode, userAppName, navigate]);

  return isCheckingAuth;
};

후기

- 처음에는 모달의 상태를 각각 관리하는 것이 크게 불편하다고 생각하지 못했다. 하지만 프로젝트에서 사용하는 모달의 수가 점점 많아지면서, 모달 상태를 효율적으로 관리하는 것의 필요성을 느꼈고, 상태 관리 라이브러리를 활용하는 방법을 도입했다.

- Zustand를 사용해 모든 모달의 상태를 한 곳에서 관리하면서, 상태 관리 로직이 흩어지지 않고 한 곳에서 통합적으로 관리된다는 점이 정말 편리했다. 특히, 모달을 열고 닫는 로직을 반복적으로 작성할 필요가 없어진 덕분에 유지보수성도 크게 향상되었다.

작업을 진행하면서 처음에 시스템을 갖추는 일이 쉽지는 않았지만, 체계적으로 시스템을 갖춰두면 이후 작업의 효율성을 크게 높일 수 있다는 것을 깨달았다.


학습 단계로 잘못된 정보가 있을 수 있습니다. 잘못된 부분에 대해 알려주시면 정정하도록 하겠습니다

참고 : https://www.mintmin.dev/blog/2407/20240721