Skip to Content
PatternsModal Patterns

Modal Patterns

모달 및 다이얼로그 구현 패턴입니다.


목차

  1. 개요
  2. React Query와 함께 사용
  3. Zustand 기반 전역 다이얼로그 시스템

개요

iCignal에서는 @vortex/ui-icignaluseDialog 훅을 사용하여 전역 다이얼로그를 관리합니다.

주요 기능

메서드반환 타입설명
alert(message)Promise<void>알림 다이얼로그 표시
confirm(options)Promise<boolean>확인/취소 다이얼로그, 결과 반환
toast(message)Promise<void>토스트 메시지 표시
showLoading()void로딩 다이얼로그 표시
hideLoading()void로딩 다이얼로그 숨기기

기본 사용법

import { useDialog } from "@vortex/ui-icignal" function Example() { const { alert, confirm, toast } = useDialog() const handleDelete = async () => { const result = await confirm({ title: "삭제 확인", description: "정말 삭제하시겠습니까?", confirmText: "삭제", confirmVariant: "destructive", }) if (result) { // 삭제 로직 toast("삭제되었습니다") } } return <button onClick={handleDelete}>삭제</button> }

React Query와 함께 사용

모달 내에서 데이터를 페칭하는 패턴입니다.

import { useState } from "react" import { useQuery } from "@tanstack/react-query" import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose, Button, Skeleton, } from "@vortex/ui-foundation" function useUser(id?: number) { return useQuery({ queryKey: ["user", id], queryFn: () => fetch(`/api/users/${id}`).then((res) => res.json()), enabled: !!id, }) } export function UserDetailModal({ userId }: { userId: number }) { const [open, setOpen] = useState(false) const { data: user, isLoading, error } = useUser(open ? userId : undefined) return ( <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger asChild> <Button variant="outline" size="sm"> 상세 보기 </Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>사용자 상세 정보</DialogTitle> <DialogDescription>사용자 정보를 확인하세요</DialogDescription> </DialogHeader> {isLoading ? ( <div className="space-y-2"> <Skeleton className="h-4 w-full" /> <Skeleton className="h-4 w-3/4" /> <Skeleton className="h-4 w-1/2" /> </div> ) : error ? ( <div className="text-destructive">에러: {error.message}</div> ) : ( <div className="space-y-2"> <p> <strong>ID:</strong> {user?.id} </p> <p> <strong>이름:</strong> {user?.name} </p> <p> <strong>이메일:</strong> {user?.email} </p> <p> <strong>역할:</strong> {user?.role} </p> </div> )} <DialogFooter> <DialogClose asChild> <Button variant="outline">닫기</Button> </DialogClose> </DialogFooter> </DialogContent> </Dialog> ) }

Zustand 기반 전역 다이얼로그 시스템

💡 @vortex/ui-icignal에는 이미 useDialogDialogProvider가 내장되어 있습니다. 아래는 직접 구현하는 경우의 상세 가이드입니다.

1. 패키지 설치

pnpm add zustand sonner

2. 타입 정의

// stores/dialog.ts import { create } from "zustand" import { toast, type ExternalToast } from "sonner" import { type ReactNode } from "react" // Alert/Confirm 아이템 타입 export interface AlertConfirmItem { id?: string type?: "alert" | "confirm" title?: string description?: string confirmText?: string cancelText?: string confirmVariant?: "default" | "outline" | "destructive" cancelVariant?: "default" | "outline" | "destructive" confirmSize?: "sm" | "default" | "lg" cancelSize?: "sm" | "default" | "lg" onConfirm?: () => void onCancel?: () => void } // Toast 아이템 타입 export interface ToastItem extends ExternalToast { type?: "success" | "error" | "loading" | "info" | "warning" id?: string title?: string description?: string dismissible?: boolean position?: | "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right" icon?: ReactNode confirmText?: string cancelText?: string onConfirm?: () => void onCancel?: () => void }

3. Store 상태 및 액션 정의

// stores/dialog.ts (계속) type State = { items: AlertConfirmItem[] loading?: boolean defaultToastPosition?: ToastItem["position"] } type Action = { alert: (item: AlertConfirmItem | string) => Promise<void> confirm: (item: AlertConfirmItem | string) => Promise<boolean> showLoading: () => void hideLoading: () => void toast: (item: ToastItem | string) => Promise<void> setDefaultToastPosition: (position: ToastItem["position"]) => void }

4. Zustand Store 구현

// stores/dialog.ts (계속) export const useDialog = create<State & Action>((set) => ({ items: [], loading: false, defaultToastPosition: "top-right", // Alert: 알림만 표시 (확인 버튼만) alert: (item: AlertConfirmItem | string) => new Promise((resolve) => { const id = Math.random().toString(36).substring(2, 9) function onConfirm() { resolve() set((state: State) => ({ items: state.items.filter((i) => i.id !== id), })) if (typeof item !== "string") item.onConfirm?.() } function onCancel() { resolve() set((state: State) => ({ items: state.items.filter((i) => i.id !== id), })) if (typeof item !== "string") item.onCancel?.() } const newItem: AlertConfirmItem = typeof item === "string" ? { title: item, type: "alert", id, onConfirm, onCancel } : { ...item, type: "alert", id, onConfirm, onCancel } set((state: State) => ({ items: [...state.items, newItem] })) }), // Confirm: 확인/취소 선택, boolean 반환 confirm: (item: AlertConfirmItem | string) => new Promise((resolve) => { const id = Math.random().toString(36).substring(2, 9) function onConfirm() { resolve(true) set((state: State) => ({ items: state.items.filter((i) => i.id !== id), })) if (typeof item !== "string") item.onConfirm?.() } function onCancel() { resolve(false) set((state: State) => ({ items: state.items.filter((i) => i.id !== id), })) if (typeof item !== "string") item.onCancel?.() } const newItem: AlertConfirmItem = typeof item === "string" ? { title: item, type: "confirm", id, onConfirm, onCancel } : { ...item, type: "confirm", id, onConfirm, onCancel } set((state: State) => ({ items: [...state.items, newItem] })) }), // 로딩 다이얼로그 showLoading: () => set({ loading: true }), hideLoading: () => set({ loading: false }), // Toast 메시지 (sonner 사용) toast: (item: ToastItem | string) => new Promise<void>((resolve) => { if (typeof item === "string") { toast(item, { onDismiss: () => resolve() }) return } const method = item.type ? toast[item.type] : toast method(item.title, { ...item, onDismiss: () => resolve(), action: item.confirmText ? { label: item.confirmText, onClick: item.onConfirm } : undefined, cancel: item.cancelText ? { label: item.cancelText, onClick: item.onCancel } : undefined, }) }), setDefaultToastPosition: (position) => set({ defaultToastPosition: position }), }))

5. DialogProvider 구현

// providers/dialog-provider.tsx import { useDialog } from "@/stores/dialog" import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, } from "@vortex/ui-foundation" import { Toaster } from "sonner" import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon, } from "lucide-react" // 로딩 다이얼로그 컴포넌트 function LoadingDialog() { return ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="rounded-lg bg-background p-6"> <Loader2Icon className="h-8 w-8 animate-spin" /> </div> </div> ) } export function DialogProvider({ children }: { children: React.ReactNode }) { const { items, loading, defaultToastPosition } = useDialog() return ( <> {children} {/* Alert/Confirm 다이얼로그 렌더링 */} {items.map((item) => ( <AlertDialog key={item.id} open onOpenChange={item.onCancel}> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>{item.title}</AlertDialogTitle> {item.description && ( <AlertDialogDescription> {item.description} </AlertDialogDescription> )} </AlertDialogHeader> <AlertDialogFooter> {item.type === "confirm" && ( <AlertDialogCancel onClick={item.onCancel}> {item.cancelText || "취소"} </AlertDialogCancel> )} <AlertDialogAction onClick={item.onConfirm} className={ item.confirmVariant === "destructive" ? "bg-destructive text-destructive-foreground hover:bg-destructive/90" : "" } > {item.confirmText || "확인"} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> ))} {/* 로딩 다이얼로그 */} {loading && <LoadingDialog />} {/* Toast 컨테이너 */} <Toaster position={defaultToastPosition} richColors icons={{ success: <CircleCheckIcon className="size-4" />, info: <InfoIcon className="size-4" />, warning: <TriangleAlertIcon className="size-4" />, error: <OctagonXIcon className="size-4" />, loading: <Loader2Icon className="size-4 animate-spin" />, }} /> </> ) }

6. App에 Provider 연결

// main.tsx 또는 App.tsx import { DialogProvider } from "@/providers/dialog-provider" function App() { return ( <DialogProvider> <Router /> </DialogProvider> ) }

7. 컴포넌트에서 사용

import { useDialog } from "@/stores/dialog" export function UserList() { const { alert, confirm, toast, showLoading, hideLoading } = useDialog() const handleDelete = async (user: User) => { // 1. 확인 다이얼로그 const result = await confirm({ title: "사용자 삭제", description: `${user.name} 사용자를 삭제하시겠습니까?`, confirmText: "삭제", cancelText: "취소", confirmVariant: "destructive", }) if (!result) return // 2. 로딩 표시 showLoading() try { await deleteUser(user.id) hideLoading() // 3. 성공 토스트 toast({ type: "success", title: "사용자가 삭제되었습니다" }) } catch (error) { hideLoading() // 4. 에러 알림 await alert({ title: "삭제 실패", description: "사용자 삭제에 실패했습니다. 다시 시도해주세요.", }) } } return <button onClick={() => handleDelete(user)}>삭제</button> }

8. Toast 타입별 사용

const { toast } = useDialog() // 기본 토스트 toast("메시지") // 성공 토스트 toast({ type: "success", title: "저장되었습니다" }) // 에러 토스트 toast({ type: "error", title: "오류가 발생했습니다" }) // 경고 토스트 toast({ type: "warning", title: "주의가 필요합니다" }) // 정보 토스트 toast({ type: "info", title: "알림", description: "추가 정보입니다" }) // 액션 버튼이 있는 토스트 toast({ title: "변경사항이 있습니다", confirmText: "저장", cancelText: "취소", onConfirm: () => save(), onCancel: () => discard(), })

주요 특징

  • Promise 기반: await confirm()으로 동기적 코드 스타일
  • 다중 다이얼로그: 여러 다이얼로그 동시 표시 가능
  • 로딩 상태: 전역 로딩 다이얼로그 지원
  • Toast 통합: sonner 라이브러리로 토스트 메시지
  • 타입 안전성: TypeScript로 완전한 타입 지원

관련 패턴

Last updated on