본문 바로가기

리액트 심화 스터디

🏄‍♀️ 리심스 4주차 - 컴포넌트 패턴

iuoo1. Compound Pattern

합성 컴포넌트 패턴은 하나의 완결된 UI 컴포넌트를 위해서 작게 세분화된(캡슐화된) 하위 컴포넌트가 존재하고, 그 하위 컴포넌트들을 조합하여 사용하는 디자인 패턴이다. 하위 컴포넌트들과, 하위 컴포넌트들 활용하는 메인 컴포넌트에서는 상태와 로직을 공유한다.

부모에서 props 로 자식에게 속성들을 전달하는 것이 일반적인데, 부모와 자식 사이의 간격이 너무 멀어질 경우, props를 계속해서 하위 컴포넌트로 넘겨줘야 하는 불편함이 발생한다.(props drilling) 

-> 이를 개선하기 위해 나온 패턴으로, 컨텍스트를 이용하여 props로 자식에게 넘기지 않고도 하위에서 필요한 데이터들을 전달한다.

 

부모 컴포넌트는 컨텍스트와 상태를 제공하는 반면 자식 컴포넌트는 이 컨텍스트를 사용하여 UI 부분을 렌더링한다.

부모의 어떤 상태를 자식에서 복합적으로 사용하고 싶을 때, 사용자가 컴포넌트와 상호작용할 수 있도록 깔끔하고 직관적인 API를 제공하면서 구현 세부 사항은 숨기는 것(캡슐화)이 핵심이다.

 

 

Compound 패턴을 사용하지 않은 일반적인 모달 구현

// 모달 구현부
function Modal({children, onClose}) {
  return <StyledModal onClick={onClose}>
      <button onClick={onClose}>X</button>
      <div>{children}</div>
    </StyledModal>;
}

// 모달 사용부
export default function App() {
  const [isOpenModal, setOpenModal] = useState(false);

  return (
      <StyledModalWrapper>
        <button onClick={() => setOpenModal(!isOpenModal)}>Open Modal</button>
        {isOpenModal && <Modal onClose={() => setOpenModal(false)}>
          <Form onClose={() => setOpenModal(false)}/>
        </Modal>}
      </StyledModalWrapper>
  );
}

 

모달 컴포넌트 밖인, 모달을 사용하는 곳에서 모달을 열고 닫는 상태를 관리하고 있다. App 컴포넌트는 Modal이 오픈되었는지 여부에 대해 몰라도 되는 속성을 단지 Modal을 보여주기 위한 용도로 상태를 관리하는 책임을 지고 있다.

 

Compound pattern을 이용하여 구현한다면, Modal이 보여지는지 말지는 Modal 컴포넌트에서 내부적으로 캡슐화하여 관리할 수 있다.

 

Compound pattern으로 구현한 모달 

// 모달 구현부
const ModalContext = createContext();

function Modal({children}) {
  const [openName, setOpenName] = useState('');

  const open = setOpenName;
  const close = () => setOpenName('');

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

function Open({children, opens}) {
  const {open} = useContext(ModalContext);
  return cloneElement(children, {onClick: () => open(opens)});
}

function Window({children, name}) {
  const {openName, close} = useContext(ModalContext);

  if(name !== openName) return null;
  return createPortal(<Overlay onClick={close}>
    <StyledModal>
      <button onClick={close}>x</button>
      <div>{children}</div>
    </StyledModal>
  </Overlay>, document.body);
}

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

export default Modal;


// 모달 사용부
<Modal>
  <Modal.Open opens={'name-form'}>
    <button>Open NameModal</button>
  </Modal.Open>
  <Modal.Window name={'name-form'}>
    <NameForm/>
  </Modal.Window>
</Modal>

 

사용부가 훨씬 깔끔해졌다.

 

장점

  • 유연성: 자식 구성 요소는 다른 순서로 재배열, 생략 또는 반복될 수 있다.
  • 재사용성: 구성요소는 다양한 콘텐츠와 함께 다양한 맥락에서 재사용될 수 있다.
  • 관심사 분리: 상태 관리와 UI 렌더링이 분리되어 구성 요소의 유지 관리와 테스트가 더 쉬워졌다.

단점

 요구사항이 제한적이고 단순한 컴포넌트라면 다른 디자인 패턴을 이용하여 구현하는 것이 훨씬 간단할 수 있다.

 

 

자주 쓰이는 곳:

  • 사용자가 정의 가능한 데이터 테이블
  • 미디어 플레이어 (재생, 정지 등의 기능이 있는)
  • 탐색 메뉴 (드롭다운 등)

 

2. HOC 패턴 (고차 컴포넌트 패턴)

고차 컴포넌트(HOC)란, 컴포넌트를 가져와 새 컴포넌트를 반환하는 함수이다. 

const EnhancedComponent = higherOrderComponent(WrappedComponent);

 

컴포넌트는 props를 UI로 변환하는 반면에, 고차 컴포넌트는 컴포넌트를 새로운 컴포넌트로 변환한다.

 

같은 로직을 여러 컴포넌트에서 재사용하는 방법 중 하나로 고차 컴포넌트 패턴을 활용하는 방법이 있다. 이 패턴은 앱 전반적으로 재사용 가능한 로직을 여러 컴포넌트들이 쓸 수 있게 해 준다.

function withStyles(Component) {
  return props => {
    const style = { padding: '0.2rem', margin: '1rem' }
    return <Component style={style} {...props} />
  }
}

const Button = () = <button>Click me!</button>
const Text = () => <p>Hello World!</p>

const StyledButton = withStyles(Button)
const StyledText = withStyles(Text)

 

위의 예제에서 Button 컴포넌트와 Text 컴포넌트를 수정한 StyledButton과 StyledText컴포넌트를 만들었다. 두 컴포넌트 모두 withStyles HOC로부터 스타일링 로직이 적용되었다.

 

주로 사용되는 경우

  • 앱 전반적으로 동일하며 커스터마이징 불가능한 동작이 여러 컴포넌트에 필요한 경우
  • 컴포넌트가 커스텀 로직 추가 없이 단독으로 동작할 수 있어야 하는 경우

래핑된 컴포넌트에 동일한 props를 제공한다면 다른 고차 컴포넌트를 쉽게 변경할 수 있다. 예를 들어 데이터를 가져오는 라이브러리를 변경하는 경우 유용하게 사용할 수 있다.

 

Compositon

고차 컴포넌트 내부에서 컴포넌트의 프로토타입을 수정(또는 변경)하지 않도록 한다.

function logProps(InputComponent) {
  InputComponent.prototype.componentDidUpdate = function(prevProps) {
    console.log('Current props: ', this.props);
    console.log('Previous props: ', prevProps);
  };
  // 원본의 입력을 반환한다는 것은 이미 변형되었다는 점을 시사합니다.
  return InputComponent;
}

// EnhancedComponent 는 props를 받을 때 마다 log를 남깁니다.
const EnhancedComponent = logProps(InputComponent);

 

위 예시 코드를 보면, 입력된 컴포넌트는 확장된(enhanced) 컴포넌트와 별도로 재사용 할 수 없다는 문제가 있다는 것을 알 수 있다. componentDidUpdate를 변형하는 EnhancedComponent에 또 다른 HOC를 적용하면 첫 번째 HOC의 기능은 무시되고, 이 HOC는 생명주기 메서드가 없는 함수 컴포넌트에서도 작동하지 않는다.

 

Consumer는 다른 HOC와의 충돌을 피하기 위하여 어떻게 구현되어있는지 반드시 알아야 한다.

즉 HOC는 변경 대신에 입력 컴포넌트를 컨테이너 구성요소로 감싸서 조합(composition)을 사용해야 한다.

function logProps(WrappedComponent) {
  return class extends React.Component {
    componentDidUpdate(prevProps) {
      console.log('Current props: ', this.props);
      console.log('Previous props: ', prevProps);
    }
    render() {
      // 들어온 component를 변경하지 않는 container입니다. 좋아요!
      return <WrappedComponent {...this.props} />;
    }
  }
}

 

위 고차 컴포넌트는 충돌 가능성을 피하면서 아까와 동일하게 작동한다. 또 순수 함수이기 때문에 다른 고차 컴포넌트와 같이 조합하거나 심지어 자체적으로 조합할 수 있다.

 

3. React Portal

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

ReactDOM.createPortal(child, container)

 

  • 첫 번째 인자 : 엘리먼트, 문자열, 혹은 fragment와 같은 어떤 종류이든 렌더링할 수 있는 리액트 노드
  • 두 번째 인자 : DOM 엘리먼트입니다.

보통 컴포넌트 렌더링 메서드에서 엘리먼트를 반환할 때 그 엘리먼트는 부모 노드에서 가장 가까운 자식으로 DOM에 마운트되나, 가끔 DOM의 다른 위치에 자식을 삽입하는 것이 유용할 수 있다. (ex: dialog, 툴팁 등)

 

Portal을 통한 이벤트 버블링

portal 내부에서 발생한 이벤트는 DOM 트리에서는 그 상위가 아니라 하더라도 React 트리에 포함된 상위로 전파될 것이다.