본문 바로가기

FrontEnd

리액트 디자인 패턴 : 컴파운드 컴포넌트 패턴 [Compound Component Pattern] 2

반응형

리액트 디자인 패턴 : 컴파운드 컴포넌트에 관해 알아봅니다.

원문 : https://isamatov.com/compound-components-react/

 

Compound Component – advanced React pattern UI libraries love

Let's take a look at an advanced React pattern - Compound Component. This pattern is widely adopted by a lot of popular UI libraries.

isamatov.com

이번 포스트에서는 널리 사용되는 React 패턴인 Compound Component에 대해 알아보겠습니다.
이 패턴을 사용하면 클린하고 선언적인 방식으로 복잡한 컴포넌트를 작성할 수 있습니다.
Material UISemantic UIChakra UI, 및 기타 많은 인기 있는 React UI 라이브러리 내부에서 사용하고 있습니다.
 

컴파운드 컴포넌트 패턴이란?

컴파운드 컴포넌트 패턴을 사용하면 복잡한 컴포넌트를 빌드하면서도 선언적이고 유연한 API를 작성할 수 있습니다.
느슨하게 결합된 여러 하위 컴포넌트를 사용하여 컴포넌트를 빌드합니다.
각 컴포넌트는 다른 작업을 수행하지만 모두 동일한 암시적 상태를 공유합니다.
이러한 하위 컴포넌트가 함께 결합되어 컴파운드 컴포넌트를 생성합니다.
 

이미 당신이 이전에 본 적이 있는 패턴입니다.

export default function App() {
  return (
    <div className="App">
      <Accordion>
        <AccordionItem id="1">
          <AccordionHeader>Header 1</AccordionHeader>
          <AccordionPanel>
            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
            eiusmod tempor incididunt ut labore et dolore magna aliqua.
          </AccordionPanel>
        </AccordionItem>
        <AccordionItem id="2">
          <AccordionHeader>Header 2</AccordionHeader>
          <AccordionPanel>
            Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris
            nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
            reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
            pariatur.
          </AccordionPanel>
        </AccordionItem>
        <AccordionItem id="3">
          <AccordionHeader>Header 3</AccordionHeader>
          <AccordionPanel>
            Excepteur sint occaecat cupidatat non proident, sunt in culpa qui
            officia deserunt mollit anim id est laborum.
          </AccordionPanel>
        </AccordionItem>
      </Accordion>
    </div>
  );
}

낯익은 구조 아닌가요?
컴파운드 컴포넌트 패턴을 사용하면 API가 해당 형태로 나타납니다.

Accordion 최상단 컴포넌트는 하위 컴포넌트 전체에서 공유하는 정보에 대한 prop을 받을 수 있음
Accordion 최상단 컴포넌트는 하위 컴포넌트 전체에서 공유하는 정보에 대한 prop을 받을 수 있음

많은 UI 라이브러리가, 아래와 같이 루트 컴포넌트 아래 네임스페이스에서 하위 컴포넌트를 제공합니다.

<Dropdown text="File">
    <Dropdown.Menu>
      <Dropdown.Item text="New" />
      <Dropdown.Item text="Open..."  />
      <Dropdown.Item text="Save as..."  />
    </Dropdown.Menu>
</Dropdown>

이 접근 방식은 선택 사항이며 원하는 대로 컴포넌트를 작성할 수 있습니다.
최종 결과에 큰 영향을 미치지 않습니다.


컴파운드 컴포넌트 패턴을 사용하는 이유는 무엇인가요?

복합 컴포넌트를 이러한 방식으로 개발할 때의 주요 이점은 사용이 매우 쉽다는 것입니다.
클라이언트에 암시적 상태 덕분에 복합 컴포넌트의 내부 동작 및 구현을 숨길 수 있습니다.
동시에 클라이언트는 원하는 방식으로 하위 컴포넌트를 재정렬하고 커스터마이징 할 수 있는 유연성을 얻습니다.

Accordion의 내용을 선언적으로 나열했으나, 우리는 내부 상태에 대해 간섭할 필요가 없었다는 점을 주목하세요.
Accordion은 클릭 시 항목 축소 및 확장을 포함하여 모든 내부 상태 논리를 처리합니다.
사용자가 해야 할 일은 단지 원하는 순서대로 항목을 나열하는 것입니다.


마지막으로 Compound Component 패턴을 사용할 때의 이점을 다시 한 번살펴보겠습니다.

  1. 컴포넌트의 API가 선언적입니다.
  2. 자식 컴포넌트가 느슨하게 결합되어 있습니다.
    • 따라서 형제 컴포넌트에 영향을 주지 않으면서 자식 컴포넌트를 쉽게 재정렬, 추가 및 제거할 수 있습니다.
  3. 컴포넌트의 스타일을 지정하고 디자인을 변경하는 것이 훨씬 쉽습니다.

이제 지금까지 이야기한 Accordion을 만들어 봅시다.

과거 접근 방식과 최신 접근 방식 두 가지 방식으로 구현해 봅시다

구식 방법 

이 방법은 사용하지 말라고... 고전 소스들 중에 해당 방법으로 구현한게 있으면 건너뛰시기 바랍니다.

헬퍼 메서드

두 가지 React 메서드인 React.Children.map 및 cloneElement를 사용하여 자식 간에 상태를 공유하는 기존 방식입니다.
간단히 살펴보겠습니다.

React.Children.map을 사용하면 배열을 반복하는 것과 같은 방식으로 컴포넌트의 children 속성을 반복할 수 있습니다.
공식 문서에 따르면: thisArg로 설정된 children 내에 포함된 모든 직계 자식(child)에 대해 함수를 호출합니다.
children이 배열이면 배열을 순회하며 배열의 각 자식에 대해 함수를 호출합니다.

Accordion

Accordion은 모든 자식과 상태를 공유하는 부모 컴포넌트 입니다.

import styled from "@emotion/styled";
import { Children, cloneElement, ReactNode, useState } from "react";
 
const AccordionContainer = styled.div`
  border: 1px solid #d3d3d3;
  border-radius: 8px;
  padding: 8px;
`;

 
function Accordion({ children }: { children: ReactNode }) {
  const [openItem, setOpenItem] = useState(null);

  return (
    <AccordionContainer>
      {Children.map(children, (child: any) =>
        cloneElement(child, { openItem, setOpenItem })
      )}
    </AccordionContainer>
  );
}

React.Children.map과 cloneElement를 함께 사용하여 children 속성을 순회합니다.
각 자식 컴포넌트를 복제하고 현재 활성 항목을 설정하는 데 사용할 추가 props인 openItem 및 setOpenItem을 전달합니다.

AccordionItem

AccordionItem은 부모이자 자식이라는 점에서 흥미롭습니다.
Accordion 컴포넌트의 자식이지만
AccordionHeader 및 AccordionPanel 컴포넌트의 부모이기도 합니다.
AccordionItem은 부모이자 자식이라는 점에서 흥미롭습니다.
또한 CloneElement를 사용하여 Accordion에서 받은 props를 자식으로 전달합니다.
여기 코드가 있습니다.
export const AccordionItem = ({
  children,
  openItem,
  setOpenItem,
  id
}: {
  children: ReactNode;
  openItem?: string;
  setOpenItem?: any;
  id: string;
}) => {
  return (
    <div>
      {Children.map(children, (child: any) =>
        cloneElement(child, { openItem, setOpenItem, id })
      )}
    </div>
  );
};

AccordionHeader

이 컴포넌트는 각 항목을 축소/확장할 수 있는 onClick 이벤트를 구독합니다.
이를 위해 AccordionItem에서 prop으로 전달 받은 setOpenItem 메서드를 사용합니다.

import styled from "@emotion/styled";

const AccordionHeaderContainer = styled.div`
  padding: 8px 16px;
  background: #f5f5f5;
  border-top: 1px solid #d3d3d3;
  cursor: pointer;
`;

export const AccordionHeader = ({
  setOpenItem,
  id,
  children
}: {
  setOpenItem?: any;
  id?: string;
  children: ReactNode;
}) => {
  return (
    <AccordionHeaderContainer onClick={() => setOpenItem(id)}>
      {children}
    </AccordionHeaderContainer>
  );
};

AccordionPanel

이 컴포넌트는 AccordionItem의 내용을 표시하는 역할을 합니다.
openItem 속성 값에 따라 축소 또는 확장됩니다.

import styled from "@emotion/styled";

const AccordionPanelContainer = styled.div<{ height: string; padding: string }>`
  padding: ${({ padding }: { padding: string }) => padding};
  height: ${({ height }: { height: string }) => height};
  overflow: hidden;
  border-bottom: 1px solid #d3d3d3;
`;

export const AccordionPanel = ({
  children,
  openItem,
  id
}: {
  children: ReactNode;
  openItem?: string;
  id?: string;
}) => {
  return (
    <AccordionPanelContainer
      padding={openItem === id ? "16px" : "0px"}
      height={openItem === id ? "fit-content" : "0px"}
    >
      {children}
    </AccordionPanelContainer>
  );
};

이게 전부입니다!

 

이 구현은 children을 컴포지션하는 방법에 한계가 있습니다.
예를 들어 다음과 같이 다른 요소를 Accordion의 자식으로 사용하려고 하면 오류가 발생합니다.

Here’s the error:

React.Children.map은 Accordion의 직계 child에 대해서만 순회할 수 있습니다.
즉, div에 child를 중첩하면 동작하지 않습니다.

아코디언 아이템에서 프롭을 전달하지 않았기 때문

우리는 더 잘할 수 있습니다!
Context API를 사용하는 새로운 구현을 살펴보겠습니다.


새로운 접근 방식

우선, 많은 코드가 비슷할 것입니다.
주요 차이점은 React.Children.Map 및 cloneElement를 사용하여 prop을 공유하는 대신
Context API를 사용하고 Provider를 생성한다는 것입니다.


Accordion

새로운 Accordion 구현은 다음과 같습니다.
import styled from "@emotion/styled";
import {
  Children,
  cloneElement,
  Context,
  createContext,
  ReactNode,
  useContext,
  useMemo,
  useState
} from "react";

const AccordionContext: Context<{
  openItem: string;
  setOpenItem: any;
}> = createContext({
  openItem: "",
  setOpenItem: null
});
const AccordionContainer = styled.div`
  border: 1px solid #d3d3d3;
  border-radius: 8px;
  padding: 8px;
`;

function Accordion({ children }: { children: ReactNode }) {
  const [openItem, setOpenItem] = useState("");

  const value = useMemo(() => ({ openItem, setOpenItem }), [openItem]);

  return (
    <AccordionContainer>
      <AccordionContext.Provider value={value}>
        {children}
      </AccordionContext.Provider>
    </AccordionContainer>
  );
}

AccordionContext를 생성하고 AccordionContext.Provider를 사용하여

openItem 및 setOpenItem props를 전달합니다.
또한 중복 리렌더링을 방지하기 위해 useMemo로 값을 래핑한 방법에 유의하세요.

AccordionItem

AccordionItem의 코드는 다음과 같습니다.
export const AccordionItem = ({
  children,
  id
}: {
  children: ReactNode;
  id: string;
}) => {
  return (
    <div>
      {Children.map(children, (child: any) => cloneElement(child, { id }))}
    </div>
  );
};

Accordion에서 받은 props를 더 이상 전달할 필요가 없다는 점을 주목하세요.
하지만 여기서 id prop을 전달하기 위해 여전히 cloneElement를 사용하고 있습니다.

AccordionPanel & AccordionHeader

 

자식 컴포넌트는 이제 useContext 후크를 사용하여 컨텍스트에서 직접 공유 상태를 가져옵니다.
다음은 둘 모두의 구현입니다.

const useAccordionContext = () => useContext(AccordionContext);

const AccordionHeaderContainer = styled.div`
  padding: 8px 16px;
  background: #f5f5f5;
  border-top: 1px solid #d3d3d3;
  cursor: pointer;
`;

const AccordionPanelContainer = styled.div<{ height: string; padding: string }>`
  padding: ${({ padding }: { padding: string }) => padding};
  height: ${({ height }: { height: string }) => height};
  overflow: hidden;
  border-bottom: 1px solid #d3d3d3;
`;

export const AccordionHeader = ({
  id,
  children
}: {
  id?: string;
  children: ReactNode;
}) => {
  const { setOpenItem } = useAccordionContext();
  return (
    <AccordionHeaderContainer onClick={() => setOpenItem(id)}>
      {children}
    </AccordionHeaderContainer>
  );
};

export const AccordionPanel = ({
  children,
  id
}: {
  children: ReactNode;
  id?: string;
}) => {
  const { openItem } = useAccordionContext();
  return (
    <AccordionPanelContainer
      padding={openItem === id ? "16px" : "0px"}
      height={openItem === id ? "fit-content" : "2px"}
    >
      {children}
    </AccordionPanelContainer>
  );
};

이 구현은 더 적은 코드와 더 나은 성능을 제공합니다.
이는 cloneElement가 약간의 성능 저하를 야기하기 때문입니다.

더 중요한 점은 Accordion에 훨씬 더 유연한 API가 있고
이전에 중단된 예제 코드가 이제 작동한다는 것입니다.


결론

컴파운드 컴포넌트 패턴으로 빌드된 컴포넌트는 사용하는 즐거움이 있습니다.
이 패턴을 사용하면 디자인 및 기능 요구 사항의 지속적인 변화에 더 잘 대비할 수 있습니다.
변화는 소프트웨어를 만들 때 항상 일어나는 일입니다.
 
이 게시물이 도움이 되었기를 바랍니다!
해당 개념을 설명하는 훌륭한 기사(this great article)를 제공한 Ken C. Todds에게 감사드립니다.
다음은 이 가이드의 전체 코드가 포함된 CodeSandbox에 대한 link입니다. 
반응형