Skip to content

모달 창 사용 예제

confirmDialog/alertDialog가 예/아니오 선택을 Promise로 돌려주는 것과 달리, Modal은 폼 등 임의의 내용을 담을 수 있는 선언형 컴포넌트다 — 열림 상태(open/handleClose)를 부모가 직접 들고 제어한다.

  1. 기본 푸터는 확인/취소 두 버튼이다. footer를 직접 넘기면 그 내용을 그대로 쓰고, handleConfirm만 넘기면 기본 확인/취소 버튼이 생성된다. handleExtra를 함께 넘기면 취소 버튼 왼쪽에 세 번째 버튼(순서: 세 번째 → 취소 → 확인)이 추가된다. handleCancel을 넘기지 않으면 취소 버튼은 handleClose를 그대로 호출한다.
    components/molecules/modal/Modal.tsx
    import {CloseButton, Dialog, Portal} from '@chakra-ui/react'
    import {Button} from '../../atoms'
    export const Modal = ({
    open,
    handleClose,
    title,
    footer,
    confirmText,
    cancelText,
    confirmColorPalette,
    handleConfirm,
    handleCancel,
    extraText,
    extraColorPalette,
    handleExtra,
    size,
    width,
    height,
    fullScreen = false,
    children,
    }: ModalProps) => {
    const handleCancelClick = () => {
    if (handleCancel) {
    handleCancel()
    return
    }
    handleClose()
    }
    const resolvedFooter =
    footer ??
    (handleConfirm && (
    <>
    {handleExtra && (
    <Button
    variant="ghost"
    colorPalette={extraColorPalette ?? 'gray'}
    handleClick={handleExtra}
    >
    {extraText ?? '추가'}
    </Button>
    )}
    <Button variant="outline" colorPalette="gray" handleClick={handleCancelClick}>
    {cancelText ?? '취소'}
    </Button>
    <Button colorPalette={confirmColorPalette ?? 'brand'} handleClick={handleConfirm}>
    {confirmText ?? '확인'}
    </Button>
    </>
    ))
    return (
    <Dialog.Root
    open={open}
    onOpenChange={handleOpenChange}
    size={size ?? 'md'}
    placement="center"
    scrollBehavior="inside"
    >
    <Portal>
    <Dialog.Backdrop />
    <Dialog.Positioner>
    <Dialog.Content width={resolvedWidth} maxW={resolvedWidth} height={resolvedHeight} maxH={resolvedHeight}>
    {title && (
    <Dialog.Header>
    <Dialog.Title>{title}</Dialog.Title>
    </Dialog.Header>
    )}
    <Dialog.Body>{children}</Dialog.Body>
    {resolvedFooter && <Dialog.Footer>{resolvedFooter}</Dialog.Footer>}
    <Dialog.CloseTrigger asChild>
    <CloseButton size="sm" />
    </Dialog.CloseTrigger>
    </Dialog.Content>
    </Dialog.Positioner>
    </Portal>
    </Dialog.Root>
    )
    }
  2. handleConfirm/handleExtra가 Promise를 반환하면 완료까지 해당 버튼에 스피너가 뜬다(Button (@/components)의 handleClick 동작을 그대로 따른다). 오른쪽 위 닫기 버튼(Dialog.CloseTrigger)은 footer 구성과 무관하게 항상 표시된다.
  3. 크기는 fullScreen > width/height > size 순으로 우선한다 — fullScreen이면 나머지는 모두 무시하고 뷰포트에서 상하좌우 20px씩 뺀 크기로 띄운다.

사용 예: react-hook-form + zod 등록/수정 모달

Section titled “사용 예: react-hook-form + zod 등록/수정 모달”
  1. domain prop 유무로 등록/수정 모드를 가른다 — 있으면 PUT(수정), 없으면 POST(등록). 아래는 모달과 관련된 부분만 옮긴 것이다. 같은 파일 위쪽에 정의된 zod 스키마(domainFormSchema), 초기값 (DOMAIN_FORM_INITIAL), 변환 함수(toDomainFormValues/toDomainRequestPayload)는 폼 작성 가이드에서 설명한다.
    pages/word/domain/components/DomainFormModal.tsx
    export const DomainFormModal = ({open, handleClose, domain}: DomainFormModalProps) => {
    const {saveDomain} = useDomainSaveMutation()
    const {updateDomain} = useDomainUpdateMutation()
    const {register, control, handleSubmit, reset, formState: {errors}} = useForm<DomainFormValues>({
    resolver: zodResolver(domainFormSchema),
    defaultValues: DOMAIN_FORM_INITIAL,
    })
    // 모달이 열릴 때마다 그 시점의 domain 기준으로 폼을 다시 채운다.
    useEffect(() => {
    if (open) {
    reset(domain ? toDomainFormValues(domain) : DOMAIN_FORM_INITIAL)
    }
    }, [open, domain, reset])
    const handleConfirm = handleSubmit(async (values) => {
    const payload = toDomainRequestPayload(values)
    try {
    if (domain && isNotNull(domain.id)) {
    await updateDomain({id: domain.id, payload})
    } else {
    await saveDomain(payload)
    }
    handleClose()
    } catch {
    // 실패 토스트는 useDomainSaveMutation/useDomainUpdateMutation의 onError가 이미 띄운다.
    }
    })
    return (
    <Modal
    open={open}
    handleClose={handleClose}
    title={domain ? '도메인 수정' : '도메인 등록'}
    confirmText={domain ? '수정' : '등록'}
    handleConfirm={handleConfirm}
    >
    <FormField<DomainFormValues>
    name="name"
    label="이름"
    register={register}
    error={errors.name}
    required
    />
    {/* 나머지 폼 필드 */}
    </Modal>
    )
    }
  2. handleConfirm은 react-hook-form의 handleSubmit으로 감싼다 — 검증에 실패하면 onSubmit을 호출하지 않고 각 필드의 에러만 채우므로 모달은 열린 채로 남는다. 요청이 실패해도(에러 토스트는 뮤테이션이 띄운다) 모달을 열어둔 채 사용자가 입력을 고쳐 다시 시도할 수 있게 한다.
  3. 성공 시에만 handleClose()를 호출해 모달을 닫는다.
  4. 부모 페이지는 페이지 훅(useDomain)에서 열림 상태(isFormModalOpen)와 수정 대상(editingDomain)을 useState로 들고 있다가 모달에 넘긴다. 모달을 닫아도 폼을 따로 비울 필요는 없다 — 다음에 열릴 때 위 useEffect가 다시 채운다.