Compound Component Pattern

Seju·2024년 1월 2일
1

DesignPattern

목록 보기
1/2

Why Compound Component?


컴포넌드 컴파운트 패턴에 대해 관심을 가지게 된 계기는 작년 12월에 들었던
원티드 프리온보딩 프론트엔드 12월 챌린지에서 비즈니스 로직과 기능이 섞인다면 "소프트웨어""하드"해지는 나중에는 겉잡을 수 없이 복잡해지는 혹은 재사용이 불가능한 컴포넌트가 된다는 설명에서 디자인 패턴 중 하나인 컴포넌드 컴파운드 패턴에 대해 간략한 설명이 있어 해당 패턴에 관심이 생겨 더 알아보게 되었다.

컴파운드 컴포넌트의 예시


      <Card>
        <Card.CardContent>
          {content.map((item, i) => {
            return <CardItem key={i} item={item} />;
          })}
        </Card.CardContent>
  
        <Card.Expand>
          <div>show more</div>
        </Card.Expand>
  
        <Card.Collapse>
          <div>show less</div>
        </Card.Collapse>
      </Card>

컴파운드 컴포넌트 패턴을 사용함에 있어 장점은 뭘까?


일반적인 props를 전달하는 패턴으로 Card 컴포넌트를 만들어보자

  • isCollapsed 상태에 따라서 전역변수로 설정한 LIMIT(=3)만큼 보여주고, show more버튼을 클릭하면 모든 컨텐츠가 렌더링되도록 하고 있다.
  • 코드는 기능적으로 정상적으로 동작하고 있다. 그러나, 아래 코드에서 발생할 문제점은 뭘까?

🪄 App.jsx


import Card from "./components/Card/Card";
import CONTENTS from "./constants/CONTENTS.JS";

function App() {
  return (
    <div>
      <Card
        contents={CONTENTS}
        expandLabel="더보기"
        collapseLabel="접기"
      />
    </div>
  );
}

export default App;


🪄 Card.jsx

import {useState} from "react";

const LIMIT = 3;

const Card = ({expandLabel, collapseLabel, contents}) => {
  const [isCollapsed, setIsCollapsed] = useState(true);

  const handleToggle = () => {
    setIsCollapsed(!isCollapsed);
  };

  return (
    <div>
      <ul>
        {contents.map((content, i) => {
          if (isCollapsed) {
            while (LIMIT > i) {
              return <p key={i}>{content.displayName}</p>;
            }
          } else {
            return <p key={i}>{content.displayName}</p>;
          }
        })}
      </ul>
      <button onClick={handleToggle}>
        {isCollapsed ? expandLabel : collapseLabel}
      </button>
    </div>
  );
};

export default Card;

1. 만약 Card 컴포넌트에서 추가적인 요구사항이 생긴다면?

😖 예를 들어서..

  • CardItem에 대한 좋아요 로직 추가하기
  • 특정 조건에 따른 렌더링 로직 추가하기
  • isCollapsed 상테에 따른 다양한 스타일 추가하기

이러한 추가적인 비즈니스로직이 생길때마다 Card 컴포넌트 하나가 그러한 요구사항들을 직접관리하려고하면 Card컴포넌트는 점점 더 복잡해지고 또한 전달해야할 props도 많아질것이고, 가독성 또한 나빠질것이다.

2. 확장성 부족과 재사용성 제한

확장성 부족

  • 현재 Card 컴포넌트는 특정한 형태의 컨텐츠만을 다루는 구조로 이루어져있다.
  • 만약, 다른 형태의 컨텐츠를 추가하거나, 특정 컨텐츠에만 적용되는 로직이 필요해진다면, Card 컴포넌트는 수정해야한다.
    • 결국에는 1번의 문제와 같이 Card 컴포넌트는 더욱 복잡해질 것이다.

재사용성 제한

  • Card 컴포넌트에서 만약 부분적인 기능이나 스타일을 재사용할때, 현재의 구조에선 이를 구현하기가 어렵다.
    • 예를 들어서 Card 컴포넌트에서 더보기 버튼이나 접기 버튼만을 재사용하고 싶을때, Card 컴포넌트 전체를 재사용해야한다.
    • 이는 필요이상의 기능과 스타일을 가져오게 되며 재사용성이 제한되게 된다.

Card 컴포넌트 컴파운드 컴포넌트 패턴으로 리팩토링하기


Context API

컴파운드 컴포넌트 패턴 구조에선 하위 컴포넌트가 될 컴포넌트들이 상위 컴포넌트의 상태를 공유하거나, 상태 변경함수를 사용해야할 경우가 많다

  • 이럴때 useContext를 사용한다면, 컴포넌트 트리 내부에서 전역적으로 데이터를 공유할 수 있게된다.

useContext와 컴파운드 컴포넌트 패턴을 활용 해 Card 컴포넌트 리팩토링

먼저 cardContext라는 변수에 createContext 반환값으로 할당한다.

  • 이를 통해 CardContext.Provider를 통해 value를 하위 컴포넌트에 상태 및 함수들을 전역적으로 공급해줄 수 있게되었다.
  • 이후, CardContentUI를 렌더링하는 "로직"만, ExpandCollapse상태를 토글하는 "로직"으로 분리되었다.
  • Card.CardContent = CardContent 이 부분들이 있는데,
    • 자바스크립트에서 함수란 결국 객체이다.
    • 따라서 가장 상위 컴포넌트이자 객체인 Card의 프로퍼티로 CardContent,Expand,Collapse로 추가한것 뿐이다
  • 이로써 서로간의 관심사가 분리 된 컴파운드 컴포넌트 패턴으로 만들어진 Card 컴포넌트가 만들어졌다.

// 🪄 리팩토링한 Card.jsx

const CardContext = createContext();
const LIMIT = 3;

const Card = ({children}) => {
  const [isCollapsed, setIsCollapsed] = useState(true);

  const expand = () => {
    setIsCollapsed(!isCollapsed);
  };

  const collapse = () => {
    setIsCollapsed(!isCollapsed);
  };

  const value = {isCollapsed, expand, collapse};

  return <CardContext.Provider value={value}>{children}</CardContext.Provider>;
};

const CardContent = ({children}) => {
  const {isCollapsed} = useContext(CardContext);
  return children.map((child, index) => {
    if (isCollapsed) {
      while (LIMIT > index) {
        return <div key={index}>{child}</div>;
      }
    } else {
      return <div key={index}>{child}</div>;
    }
  });
};

const Expand = ({children}) => {
  const {expand, isCollapsed} = useContext(CardContext);
  return isCollapsed && cloneElement(children, {onClick: expand});
};

const Collapse = ({children}) => {
  const {collapse, isCollapsed} = useContext(CardContext);
  return !isCollapsed && cloneElement(children, {onClick: collapse});
};

Card.CardContent = CardContent;
Card.Expand = Expand;
Card.Collapse = Collapse;

export default Card;

App에서 사용은?

  • 이제 Card.CardContent, Card.Expand, Card.Collapse 안에 어떤 요소든 자식으로 넣을 수 있으며, 해당 요소는 항상 컴포넌트에 주어진 기능을 가지고 있다.
  • 재사용성도 뛰어나고 유연성도 높은 합성컴포넌트가 된것이다.
  • 최종적으로 요구사항이 복잡해지고, 조금 더 다양한 엣지케이스들을 고려해야할 때 컴파운드 컴포넌트 패턴을 사용하는것은 좋은 대안이 될것같다.
import Card from "./components/Card/Card";
import CONTENTS from "./constants/CONTENTS.JS";

function App() {
  return (
    <div>
      <Card>
        <Card.CardContent>
          {CONTENTS.map((item, index) => (
            <p key={index}>{item.displayName}</p>
          ))}
        </Card.CardContent>
        <Card.Collapse>
          <div>더보기</div>
        </Card.Collapse>
        <Card.Expand>
          <div>접기</div>
        </Card.Expand>
      </Card>
    </div>
  );
}

export default App;
profile
Talk is cheap. Show me the code.

0개의 댓글