리액트 디자인 패턴 : 컴파운드 컴포넌트에 관해 알아봅니다.
원문 : https://isamatov.com/compound-components-react/
컴파운드 컴포넌트 패턴이란?
이미 당신이 이전에 본 적이 있는 패턴입니다.
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을 받을 수 있음
많은 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 패턴을 사용할 때의 이점을 다시 한 번살펴보겠습니다.
- 컴포넌트의 API가 선언적입니다.
- 자식 컴포넌트가 느슨하게 결합되어 있습니다.
- 따라서 형제 컴포넌트에 영향을 주지 않으면서 자식 컴포넌트를 쉽게 재정렬, 추가 및 제거할 수 있습니다.
- 컴포넌트의 스타일을 지정하고 디자인을 변경하는 것이 훨씬 쉽습니다.
이제 지금까지 이야기한 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
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의 자식으로 사용하려고 하면 오류가 발생합니다.
React.Children.map은 Accordion의 직계 child에 대해서만 순회할 수 있습니다.
즉, div에 child를 중첩하면 동작하지 않습니다.
아코디언 아이템에서 프롭을 전달하지 않았기 때문
우리는 더 잘할 수 있습니다!
Context API를 사용하는 새로운 구현을 살펴보겠습니다.
새로운 접근 방식
우선, 많은 코드가 비슷할 것입니다.
주요 차이점은 React.Children.Map 및 cloneElement를 사용하여 prop을 공유하는 대신
Context API를 사용하고 Provider를 생성한다는 것입니다.
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
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가 있고
이전에 중단된 예제 코드가 이제 작동한다는 것입니다.
결론
'FrontEnd' 카테고리의 다른 글
AST 활용 1편 : ESLint console.log 체크 플러그인 만들기 (0) | 2022.09.19 |
---|---|
[React] React.cloneElement 사용 사례 (0) | 2022.09.15 |
[Babel] 바벨 플러그인을 작성하며 AST 배우기 (0) | 2022.09.14 |
[typescript] d.ts 파일을 js 프로젝트에서 사용할 수 있을까? (3) | 2022.09.14 |
Redux Toolkit : Usage Guide(사용 가이드) (0) | 2022.09.13 |