복잡한 Modal 설계, Compound Component로 해결하기

JunSeok·2024년 11월 22일
0

지식 기록

목록 보기
13/14
post-thumbnail

기존 Modal의 문제점

리액트에서 일반적으로 Modal은 상태를 선언하고 이를 조작하여 열고 닫는 형태로 구현된다.

const App = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>모달 열기</button>
      {isOpen && <Modal onClose={() => setIsOpen(false)} />}
    </div>
  );
};

문제점

이 방식은 직관적이고 간단하지만, Modal이 늘어날수록 같은 상태와 조작 로직을 중복 작성하게 된다.
특히 Modal을 여러 곳에서 사용해야 하는 경우, 유지 보수성이 떨어지고 상태 관리가 번거로워진다.

해결방안

Compound Component Pattern과 React Portal을 사용하면 Modal을 보다 유연하고 재사용 가능하며 유지 보수성이 높은 컴포넌트로 개선할 수 있다.

Compound Component Pattern

기본 개념

Compound Component Pattern은 부모 컴포넌트와 여러 자식 컴포넌트가 하나의 상태를 공유하여 협력적으로 동작하도록 만드는 패턴이다. 예를 들어 HTML의 selectoption처럼 동작한다.

<label for="pet-select">Choose a pet:</label>

<select name="pets" id="pet-select">
  <option value="">--Please choose an option--</option>
  <option value="dog">Dog</option>
  <option value="cat">Cat</option>
  <option value="hamster">Hamster</option>
  <option value="parrot">Parrot</option>
  <option value="spider">Spider</option>
  <option value="goldfish">Goldfish</option>
</select>

리액트에서의 Compound Component 구현 예시

1. Context 생성 및 부모 컴포넌트 구현

컴포넌트 간 상태를 공유하기 위해 Context를 생성한다.

const FlyOutContext = createContext();

function FlyOut(props) {
  const [open, toggle] = useState(false);

  const providerValue = { open, toggle };

  return (
    <FlyOutContext.Provider value={providerValue}>
      {props.children}
    </FlyOutContext.Provider>
  );
}

2. 자식 컴포넌트 생성

FlyOut에서 사용할 자식 컴포넌트를 구현한다. 자식 컴포넌트는 Context를 통해 부모의 상태를 공유한다.

function Toggle() {
  const { open, toggle } = useContext(FlyOutContext);

  return (
    <div onClick={() => toggle(!open)}>
      <span>Toggle</span>
    </div>
  );
}

function List({ children }) {
  const { open } = useContext(FlyOutContext);

  return open && <ul>{children}</ul>;
}

function Item({ children }) {
  return <li>{children}</li>;
}

3. 부모 컴포넌트에 자식 컴포넌트 연결

부모 컴포넌트의 static 속성으로 자식 컴포넌트를 추가하여 부모 컴포넌트만 import하면 자식 컴포넌트를 사용할 수 있게 한다.

FlyOut.Toggle = Toggle;
FlyOut.List = List;
FlyOut.Item = Item;

사용 예시

FlyOut 컴포넌트를 사용하여 FlyoutMenu를 구성한다. FlyoutMenu 컴포넌트는 자체적인 상태를 가지지 않는다.

import { FlyOut } from './FlyOut'

function FlyoutMenu() {
  return (
    <FlyOut>
      <FlyOut.Toggle />
      <FlyOut.List>
        <FlyOut.Item>Edit</FlyOut.Item>
        <FlyOut.Item>Delete</FlyOut.Item>
      </FlyOut.List>
    </FlyOut>
  )
}

React Portal

React Portal은 컴포넌트를 부모 컴포넌트의 계층 구조 밖에 있는 DOM 노드의 자식 컴포넌트로 렌더링하는 방법을 제공한다.

특징

독립적 스타일링

부모 컴포넌트의 계층 구조 밖에서 컴포넌트를 렌더링하기 부모 컴포넌트의 스타일에 영향을 받지 않는다.
상위 컴포넌트의 overflow: hidden 과 같은 설정과의 충돌을 피할 수 있고 z-index와 같은 속성을 쉽게 제어할 수 있어 스타일링이 간단해진다.

이벤트 버블링

Portal로 렌더링된 컴포넌트는 시각적으로 부모 컴포넌트 외부에서 렌더링되지만, 여전히 리액트의 상태나 이벤트와 연결되어 있어 이벤트 버블링이 정상적으로 동작한다.

사용 예시

ReactDOM의 createPortal 함수를 사용하여 JSX와 렌더링할 위치의 부모 DOM node 를 인수로 전달한다.

import React from 'react';
import { createPortal } from 'react-dom';

function Portal({ children }) {
  return createPortal(
    <div className="modal">{children}</div>,
    document.body
  );
}

목표

  1. Compound Component Pattern을 활용하여 상태와 로직을 Modal 컴포넌트 내부로 캡슐화함으로써 재사용성을 높인다.
  2. React Portal을 활용하여 스타일 충돌 방지 및 유연한 Modal 렌더링을 가능하게 한다.

구현 과정

1. Context 생성

const ModalContext = createContext({
  openName: '',
  open: (name: string) => { },
  close: () => { }
});

2. 부모 컴포넌트 생성

Modal의 상태와 함수를 Context Provider로 관리한다.

export const Modal = ({ children }: { children: ReactNode }) => {
  const [openName, setOpenName] = useState('')
  const close = () => setOpenName('')
  const open = (name: string) => setOpenName(name);

  return <ModalContext.Provider value={{ openName, open, close }}>
    {children}
  </ModalContext.Provider>
}

3. Open 컴포넌트 생성

Modal을 열기 위한 트리거 컴포넌트로, 버튼 같은 트리거 요소를 감싼다.

const Open = ({ children, name }: { children: ReactElement, name: string }) => {
  const { open } = useContext(ModalContext)

  return cloneElement(children, { onClick: () => open(name) })
}

4. Window 컴포넌트 생성

Modal UI를 렌더링하며 Portal을 활용한다.

const Window = ({ children, name }: { children: ReactElement, name: string }) => {
  const { openName, close } = useContext(ModalContext)

  if (openName !== name) return null

  return createPortal(
    <div className="absolute inset-0 z-50 flex items-center justify-center" onClick={handleClose}>
      <div
        className="relative z-50 flex items-center justify-center h-full w-web min-w-mobile bg-black/50"
      >
        {cloneElement(children, { onCloseModal: close })}
      </div>
    </div >,
    document.body,
  );
};

부모와 자식 연결

Modal에 Open과 Window를 연결한다.

Modal.Open = Open;
Modal.Window = Window;

사용 예시

버튼을 클릭하면 ConfirmModal을 띄우는 예제이다.

interface ConfirmProps {
  children: ReactNode
  onCloseModal?: () => void
}

// Modal 컴포넌트에서 onCloseModal 속성에 close 함수를 정의한다.
const Confirm = ({ children, onCloseModal }: ConfirmProps) => {
  return (
    <div onClick={(e) => e.stopPropagation()} >
      <div>
        Modal 내용...
      </div>
      <div>
        <button type='button' onClick={onCloseModal} >
          닫기
        </button>
        {children}
      </div>
    </div>
  );
}

const Details = () => {
  const { mutate: cancelBid } = useCancelBid()
  const clickCancel = () => cancelBid()
  
  return (
    <Modal>
      <Modal.Open name="cancelBid">
        <button>
          참여 취소
        </button>
      </Modal.Open>
      <Modal.Window name="cancelBid">
        <Confirm type="cancelBid" >
          <button onClick={clickCancel}>
            참여 취소
          </button>
        </Confirm>
      </Modal.Window>
    </Modal>
  );
}

결과

개발자 도구에서 확인하면 body태그에 Modal이 렌더링된 것을 볼 수 있다.

출처

Compound Pattern
MDN HTML select
What are React Portals?
React Portals 공식문서

profile
최선을 다한다는 것은 할 수 있는 한 가장 핵심을 향한다는 것

0개의 댓글