안녕하세요, 오늘은 전역 상태관리 라이브러리로 modal을 관리해보도록 하겠습니다.
왜 모달을 전역상태로 관리하나요? 라고 물어볼 수 있지만.. 그렇게 하면 더 편할 거 같아서..? 라는 생각에… 혹시나 가장 최적의 방법을 알고계시다면 언제든지 공유 부탁드립니다.

zustand란?

zustand[공식문서] 를 보면 정말 간단하게 설명되어있습니다. react 상태관리로는 주로 recoil이나, redux를 사용하는데 zustand의 공식문서에는 다음과 같이 설명합니다.

왜 redux 대신 zustand를 사용하나요?

  • 간단하고 unopinionated함
  • 훅을 상태 소비의 주요한 수단으로 사용함
  • 컨텍스트 프로바이더가 필요 없음

왜 context 대신 zustand를 사용하나요?

  • 적은 상용구
  • 값이 바뀔 때만 렌더링함. (기본 deepEqual)
  • 중앙화된 액션 베이스 상태 관리
이걸 보고 zustand는 참 자신감이 있고 당당하구나 라고 느꼈는데, 사용해보니 왜 그렇게 자신있었는지 알 수 있었습니다.
일단 너무 쉽고, 간단하고 추가적인 설정이 따로 필요 없다는게 러닝커브도 그렇고 매우 적을 것 같았습니다.

zustand 로 커스텀 모달 만들어보기

모달 만들기

저는 모달을 만들때 가장 기본이 되는 모달 템플릿을 하나 만들어둡니다. 큰 프로젝트일 경우 모달의 종류가 다양하고, 버튼의 형식, 바디의 내용 등 또한 다를 것입니다. 그렇기 때문에 모달에서 가장 기본이 되는 템플릿을 하나 마련해둔다면 사용할때 훨씬 큰 이득을 볼 수 있을겁니다.
TypeScript
import { useCallback, useEffect, useState } from "react";

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  body?: React.ReactElement;
}

export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, body }) => {
  const [showModal, setShowModal] = useState(isOpen);

  useEffect(() => {
    setShowModal(isOpen);
  }, [isOpen]);

  const handleClose = useCallback(() => {
    setShowModal(false);
    setTimeout(() => {
      onClose();
    }, 300);
  }, [onClose]);

  if (!isOpen) {
    return null;
  }

  return (
    <>
      <S.Container>
        <S.Dim onClick={handleClose}>
          <S.Modal showModal={showModal} onClick={(e) => e.stopPropagation()}>
            <S.Header>
              <S.CloseBtn onClick={handleClose}>x</S.CloseBtn>
            </S.Header>
            {body}
          </S.Modal>
        </S.Dim>
      </S.Container>
    </>
  );
};
간단하게 만든 modal입니다. 모달은 Dim 영역도 있고, Dim 영역을 클릭시 modal이 닫히는 구조로 되어있습니다. <S>는 styled-component로 생성한 style로 무시하셔도 되고 대충 구조가 컨테이너 안에 dim과 modal이 있다 정도로 이해해주셔도 되겠습니다.
그럼 이 모달을 어떻게 사용할까요? 사용하기 전에 우리는 custom hook을 사용해서 zustand 전역 모달 상태를 추가하도록 하겠습니다.

zustand 사용하기

Bash
npm install zustand # or yarn add zustand
를 통해 먼저 zustand 를 설치해줍니다. 설치한 zustand를 활용한 hook useDetailModal() 을 만들면 다음과 같이 만들 수 있습니다.
TypeScript
import { create } from "zustand";

interface TDetailModalStore {
  isOpen: boolean;
  onOpen: (id: string) => void;
  onClose: () => void;
}

const useDetailModal = create<TDetailModalStore>((set) => ({
  isOpen: false,
  onOpen: () => set({ isOpen: true }),
  onClose: () => set({ isOpen: false }),
}));

export default useDetailModal;
만들어진 useDetailModal을 detailmodal.tsx에서 불러 사용해보도록 하겠습니다.
TypeScript
import { useEffect, useMemo, useState } from "react";
import useDetailModal from "../../hook/useDetailModal";
import { Modal } from "./Modal";

interface CarDetailsProps {
  carDetail: TCarDetail;
}

const Detail = () => {
  return (
    <>
				detail
    </>
  );
};

export const CarDetailModal = () => {
  const detailModal = useDetailModal();

  return (
    <Modal
      isOpen={detailModal.isOpen}
      onClose={detailModal.onClose}
      body={<Detail carDetail={detail} />}
    />
  );
};
자 이렇게 modal 템플릿을 만들어 둔 것을 불러와 사용한 케이스 입니다. 여기엔 사실 header와 footer, action, secondAction 등의 메소드들도 들어갈 수 있을겁니다.
TypeScript
import useDetailModal from "../hook/useDetailModal";

export const Home = () => {
  const carDetailModal = useCarDetailModal();

  return (
		<>
			<button onClick={() => carDetailModal.onOpen()}>모달 열기</button>
		</>
    
  );
};
이렇게 필요한 곳에서 hook을 불러와 사용할 수 있을겁니다. 어떤가요 상당히 클린하고, 깔끔하고, 편하지 않나요? zustand가 깃허브에서 당당하던 이유가 여기에 있는 것 같습니다.
주로 recoil의 familySelector를 주로 사용했었는데, zustand도 한번 고려해봐야겠습니다.

결론

전역 상태의 모달을 관리하는게 좋을까? 라는 생각을 하였습니다. 하지만 상당히 쉽고, 간단하게 처리할 수 있다는 사실을 알게되었고 next.js와 같은 환경에서 client only 컴포넌트들을 만들어 사용한다면 ssr과 csr을 적절히 사용하는 프로젝트를 만들 수 있지 않을까 생각해봅니다.