본문 바로가기
Frontend/ReactJS

[React] 합성 컴포넌트 패턴으로 모달 만들기

by 모너아링 2024. 6. 12.

Introduction

 

토이 프로젝트 중 위와 같은 다양한 형태의 모달을 제작해야 할 상황이 생겼고, 예전부터 사용해보고 싶었던 합성 컴포넌트 패턴으로 모달을 제작해보기로 했습니다.

 

합성 컴포넌트 패턴(Compound Component Pattern) 이란?

합성 컴포넌트 패턴은 여러 작은 컴포넌트들을 조합하여 하나의 컴포넌트를 만드는 React 디자인 패턴입니다.

이 패턴을 사용하여 컴포넌트를 구현한다면 높은 재사용성을 갖습니다.

또한 한 컴포넌트에 무지막지한 양의 props가 주입되는 대신 상위 컴포넌트에서 각 컴포넌트에 필요한 props만 주입할 수 있기 때문에 관심사 / 책임 분리, props drilling 방지 등의 장점을 갖습니다.

 

예시로는 아코디언 컴포넌트가 있습니다. 아코디언 컴포넌트는 제목, 내용, 이들을 합친 섹션의 반복으로 이루어집니다.

이를 합성 컴포넌트를 이용하여 컴포넌트들의 조합으로 나타낼 수 있는데, 적용 전과 후의 코드를 비교해보면

Before

<div>
  {sections.map((section, index) => (
    <div key={index}>
      <button onClick={() => handleToggle(index)}>{section.title}</button>
      {openIndex === index && <div>{section.content}</div>}
    </div>
  ))}
</div>

After

<Accordion>
  <Accordion.Section index={0}>
    <Accordion.Title>Section 1</Accordion.Title>
    <Accordion.Content>Content 1</Accordion.Content>
  </Accordion.Section>
  <Accordion.Section index={1}>
    <Accordion.Title>Section 2</Accordion.Title>
    <Accordion.Content>Content 2</Accordion.Content>
  </Accordion.Section>
</Accordion>

 

이와 같이 각 부분을 별도의 컴포넌트로 분리하여 논리적으로 구조화할 수 있습니다.

 

구현하기

합성 컴포넌트 패턴이 무엇인지 알아봤으니 이를 이용하여 모달을 구현했던 과정을 살펴보겠습니다.

 

1. 구조화할 컴포넌트 정하기

구조화할 컴포넌트를 정할 때 고민이 많았습니다.

작은 부분까지 다 구조화를 해놓자니 모든 경우를 다 따지면 컴포넌트가 너무 많아질 것 같고.. 그렇다고 큰 틀만 구조화해놓자니 자유도가 너무 높아져 이게 과연 합성하는 의미가 있나? 하는 생각이 들었습니다.

하여 모달들의 공통적인 부분들만 구조화 해놓자는 결론을 냈습니다.

 

이 정도의 컴포넌트만 합성하기로 하고, 모달마다 거의 다른 Content 같은 경우는 각각 사용할 때 구현하고자 하였습니다.

 

2. ContextAPI를 사용하여 모달 여닫는 커스텀 훅 제작

공통적으로 모달을 여닫는 함수와 이에 대한 상태를 공유할 수 있어야 해서 ContextAPI 를 이용하였습니다.

interface ModalContextProps {
  isOpen: boolean;
  openModal: () => void;
  closeModal: () => void;
}

// Modal Context: isOpen, openModal, closeModal
const ModalContext = createContext<ModalContextProps | undefined>(undefined);

// context를 관리할 커스텀 훅
const useModalContext = (): ModalContextProps => {
  const context = useContext(ModalContext);
  if (!context) {
    throw new Error('useModalContext must be used within a ModalProvider');
  }
  return context;
};

interface ModalProviderProps {
  children: ReactNode
}

// context provider
const ModalProvider = ({ children }: ModalProviderProps) => {
  const [isOpen, setIsOpen] = useState(false);
  const openModal = () => setIsOpen(true);
  const closeModal = () => setIsOpen(false);

  return (
    <ModalContext.Provider value={{ isOpen, openModal, closeModal }}>
      {children}
      {isOpen && <Modal.Dimmed />}
    </ModalContext.Provider>
  )
}

export { ModalProvider, useModalContext };

 

가장 상위 폴더에 ModalProvider로 감싸 모달의 상태를 저장할 수 있도록 합니다.

 

3. Modal 컴포넌트와 각 하위 컴포넌트 구현

export const Modal = ({ children, isOpen }: ModalProps) => {
  if(!isOpen){
    return null;
  }

  const modalHeader = getModalHeader(children);
  const modalContents = getModalContents(children);
  const modalFooter = getModalFooter(children);
  return createPortal(
    <div className={styles.modal}>
      {
        modalHeader && (
          <div className={styles.modal__header}>{modalHeader}</div>
        )
      }
      {
        modalContents && (
          <div className={styles.modal__content}>{modalContents}</div>
        )
      }
      {
        modalFooter && (
          <div className={styles.modal__footer}>{modalFooter}</div>
        )
      }
    </div>
  , document.body);
}

Modal.Dimmed = ModalDimmed;
Modal.Header = ModalHeader;
Modal.Content = ModalContents;
Modal.Footer = ModalFooter;

Modal.Title = ModalTitle;
Modal.Subtitle = ModalSubtitle;
Modal.Button = ModalButtons;

 

가장 상위 컴포넌트인 Modal 컴포넌트의 children을 각각 Header, Contents, Footer로 구분했습니다. 

모달에는 React Portal를 사용했습니다.

하위 컴포넌트 구현부는 따로 기술하지 않겠습니다.

 

4. 사용하기

기본 틀은 다음과 같은 형태입니다.

<Modal isOpen={isOpen}>
    <Modal.Header>
        <Modal.Title></Modal.Title>
        <Modal.Subtitle></Modal.Subtitle>
    </Modal.Header>
    <Modal.Content>
    	~
    </Modal.Content>
    <Modal.Footer>
        <Modal.Button>
            ~
        </Modal.Button>
    </Modal.Footer>
</Modal>

 

모달마다 다른 부분은 각 컴포넌트의 children으로 구현합니다.

 

마무리

합성 컴포넌트 패턴을 활용하여 재사용성 높은 모달을 제작해봤습니다.

다른 방식도 있겠지만 저는 Context API 를 이용하여 공통적인 변수, 함수를 관리하고, React Portal을 활용하였으며, 공통적인 여러 컴포넌트를 합성하는 방식으로 진행했습니다.

원래 모달을 제작했을 때는 useState를 사용하여 값을 관리하고 공통적인 부분이 있더라도 매번 새로운 모달 컴포넌트를 제작했지만, 이 방식을 사용하니 재사용성이 훨씬 늘어난게 느껴집니다.

 

Reference

https://fe-developers.kakaoent.com/2022/220731-composition-component/

https://leetrue.hashnode.dev/component-lab-modal