팀 프로젝트를 하면서 한 번도 직접 모달을 만들어본 적이 없었는데, 이번에 개인 프로젝트를 진행하면서 처음으로 모달을 구현해봤다. 이 과정에서 Zustand를 전역 상태 관리 라이브러리로 선택했는데, 그 이유는 다음과 같다.
모달을 Context API로 관리하면 모달이 열릴 때마다 불필요한 리렌더링이 발생할 가능성이 높다고 생각했다.
Zustand를 사용하면 구독 방식으로 상태를 관리할 수 있어, 리렌더링을 최소화하면서도 모달의 열림/닫힘 상태를 쉽게 제어할 수 있다고 판단했다.
그래서 처음에는 다음과 같이 간단하게 구현했다.
import React, { ReactElement } from "react";
import { create } from "zustand";
interface ModalState {
isOpen: boolean;
openModal: () => void;
closeModal: () => void;
}
export const useModalStore = create<ModalState>((set) => ({
isOpen: false,
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false }),
}));
하지만 사용하다 보니 모달이 전역에서 하나만 열렸다 닫혔다 하는 문제가 있었다.
내가 원했던 건 각각 필요한 곳에서, 서로 다른 모달을 독립적으로 띄우는 것이었는데, 처음 열린 모달 하나만 계속 열리는 상태가 되어버렸다.
다시 다른 프로젝트들을 참고하며 어떻게 만들고 싶은지 고민해봤다.
내가 생각한 가장 좋은 방법은, 필요한 모달 컴포넌트를 openModal 함수의 인자로 전달하는 방식이었다.
const handleEdit = () => {
openModal(CreateEditTask, {
mode: "edit",
initialData: {
id,
title,
description,
dueDate,
},
status,
onTasksUpdate,
});
};
const handleDelete = () => {
openModal(DeleteModal, { id, onTasksUpdate });
};
이렇게 하면 전체 레이아웃에서 모달을 렌더링해두고, 필요한 모달을 동적으로 띄울 수 있어서 사용하기 간편해진다.
블로그에 이렇게 만든 예시도 없고, 이전 프로젝트에서도 zustand와 hook이 너무 복잡하게 얽혀있어서 간단하면서도 저런 방식으로 구현되는 모달을 만들고 싶었다.
import React, { ReactElement } from "react";
import { create } from "zustand";
interface ModalState {
isOpen: boolean;
component: ReactElement | null;
openModal: <T extends object>(
Component: React.ComponentType<T>,
props: T,
) => void;
closeModal: () => void;
}
export const useModalStore = create<ModalState>((set) => ({
isOpen: false,
component: null,
openModal: <T extends object>(
Component: React.ComponentType<T>,
props: T,
) => {
const element = React.createElement(Component, props);
set({ isOpen: true, component: element });
},
closeModal: () => set({ isOpen: false, component: null }),
}));
이렇게 바꿔보았다. 중간에 이렇게 만들 수 있기까지 수 많은 과정을 거쳤지만.. (시간이 좀 지나 기억안남 이슈🥹)
나는 Next.js의 App Router를 사용하고 있어서, useModalStore를 직접 사용하면 하위 모든 컴포넌트가 클라이언트 컴포넌트가 될 가능성이 있었다. 이를 방지하기 위해 클라이언트 전용 Provider 컴포넌트를 만들어 레이아웃에 추가했다.
// Modal Provider
"use client";
import { useEffect, useState } from "react";
import { useModalStore } from "@/store/useModalStore";
export default function ModalProvider() {
const { component } = useModalStore(); // 모달 상태에서 현재 렌더링할 컴포넌트를 가져옴
const [mounted, setMounted] = useState(false); // 컴포넌트가 마운트 되었는지 여부를 관리하는 상태
useEffect(() => {
setMounted(true); // 컴포넌트가 마운트되면 mounted 상태를 true로 설정
}, []);
if (!mounted) {
return null; // 초기 렌더링에서는 아무것도 렌더링하지 않음
}
return component; // 상태에서 가져온 컴포넌트를 렌더링
}
그리고 RootLayout에 ModalProvider를 추가해 전체 레이아웃에서 모달을 관리할 수 있도록 했다.
import "./globals.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import ModalProvider from "@/components/common/modal/modal-provider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Kanban TodoList",
description: "할 일 목록을 만들어보세요",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko">
<body className={inter.className}>
{children}
<ModalProvider />
</body>
</html>
);
}
이렇게 구현하니 사용처에서는 openModal(Component, props) 방식으로 쉽게 모달을 띄울 수 있고, 모달 자체가 어떻게 동작하는지도 이해하기 쉬운 구조가 되었다.
Zustand로 모달을 만들고 있다면 이 방식을 한 번 시도해보는 것도 좋을 것 같다🤓