공부내용 공유하기

React에서의 Modal

리액트의 모달 컴포넌트는 다양한 방식으로 사용된다.

 

모달 렌더링에 대한 책임을 버튼 컴포넌트에 넘기는 방식. 모달의 열림/닫힘 상태를 버튼에서 관리하여 보일러 플레이트 코드를 줄이기 좋다. Props에 대한 타입 전달이 자연스러럽게 되는 장점도 있다.

<ModalControlButton Modal={ShareModal} modalProps={{ url }}>Share!</ModalControlButton>

 

모달의 열림/닫힘 상태를 제어하면서 모달 컴포넌트를 렌더링하는 방식. 가장 일반적인 방식이고 러닝커브가 없다.

const modal = useModal();
//...
<Button onClick={modal.open}>Share!</Button>
<ShareModal isOpen={modal.isOpen} close={modal.close} url={url} />

 

모달을 함수 호출 시점에 렌더링하는 방식. 호출부와 렌더링 코드가 붙어있어 응집도가 높은 편이다.

const { openModal } = useModal();

const handleClick = () => openModal(ShareModal, { url });
//...
<Button onClick={handleClick}>Share!</Button>

 

모달을 함수 호출 시점에 렌더링하고 JSX구조를 유지하는 방식. 위의 방식과 유사하다.

import modal from "@~";
//...
const handleClick = () => modal.open((isOpen, close) => <ShareModal isOpen={isOpen} close={close} url={{ url }} />, );
//...
<Button onClick={handleClick}>Share!</Button>

 

모달 구현체는 일반적으로 Portal을 사용하거나 전용 Provider를 통해 렌더링 위치를 제어한다.

`createRoot().render()`를 직접 호출해서 전용 FiberTree를 만들어 제어하는 방식도 가능하다.

 

 

이 중에서 개인적으로 편하게 사용하고 있는 ModalControlButton의 구현체를 살펴보자

 

import { FC, useCallback, useState } from 'react';
import { Button, type ButtonProps } from '@/compoents/Button';

type PropsFrom<TComponent> = TComponent extends FC<infer Props> ? Props : never;

type WithModalProps<ModalRenderer> = {} extends RemoveOptional<
  Omit<PropsFrom<ModalRenderer>, 'close'>
>
  ? { modalProps?: Omit<PropsFrom<ModalRenderer>, 'close' | 'isOpen'> }
  : { modalProps: Omit<PropsFrom<ModalRenderer>, 'close' | 'isOpen'> };

type ModalControlButtonProps<ModalRenderer> = {
  ModalRenderer: ModalRenderer;
} & WithModalProps<ModalRenderer> &
  Omit<ButtonProps, 'onClick'>;

export default function ModalControlButton<
  ModalRenderer extends FC<any & { close: () => unknown }>,
>({ ModalRenderer, children, modalProps, ...restProps }: ModalControlButtonProps<ModalRenderer>) {
  const [isOpen, setIsOpen] = useState<boolean>(false);

  const open = useCallback(() => {
    setIsOpen(true);
  }, []);

  const close = useCallback(() => {
    setIsOpen(false);
  }, []);

  return (
    <>
      <Button onClick={open} {...restProps}>
        {children}
      </Button>
      <ModalRenderer {...(modalProps as any)} isOpen={isOpen} close={close} />
    </>
  );
}

/**
 * 타입 T의 키 K를 조건부로 포함하는 맵 타입을 작성합니다.
 * {}가 타입 T에서 K 키를 가진 속성을 Pick한 것에 확장할 수 있는 경우 never를 반환하고, 그렇지 않으면 K를 반환합니다.
 * 결국 필수 속성만 K로 선정됩니다.
 */
type RequiredKeys<T> = {
  [K in keyof T]-?: {} extends Pick<T, K> ? never : K;
}[keyof T];

/**
 * 주어진 타입 T에서 선택적 속성을 제거한 새 타입을 생성합니다.
 * 필수 속성들로만 이루어진 타입을 반환합니다.
 */
type RemoveOptional<T> = Pick<T, RequiredKeys<T>>;