합성 컴포넌트
합성 컴포넌트 패턴은 UI를 작성할 때 여러 개로 분리된 컴포넌트를 조합하여 컴포넌트를 개발하는 패턴이다. 컴포넌트의 조합을 활용함으로써 다양한 상황에서 컴포넌트를 도입할 수 있다.
해당 패턴으로 개발한 헤더 컴포넌트의 예시이다. 컴포넌트는 왼쪽, 가운데, 오른쪽 총 세 가지 구역으로 나뉘어져 있다.
Children.toArray 메소드로 하위 컴포넌트를 배열 타입으로 변환한 다음 각 컴포넌트의 타입을 비교함으로써 헤더 내 어디에 배치시켜야 하는지 필터링할 수 있다.
Header 컴포넌트는 HeaderContainer 컴포넌트임과 동시에 Object.assign 메소드로 타 컴포넌트들도 속성으로 추가할 수 있다.
import { Children, PropsWithChildren } from "react";
interface Props extends PropsWithChildren {
className?: string;
}
const HeaderCenter = (props: Props) => {
const { children } = props;
return children;
};
const HeaderLeft = (props: Props) => {
const { children } = props;
return children;
};
const HeaderRight = (props: Props) => {
const { children } = props;
return children;
};
export interface HeaderContainerProps extends PropsWithChildren {
className?: string;
}
const HeaderContainer = (props: HeaderContainerProps) => {
const components = Children.toArray(props.children);
const lefts = components.filter(
(comp) => (comp as JSX.Element).type === (<HeaderLeft />).type
);
const centers = components.filter(
(comp) => (comp as JSX.Element).type === (<HeaderCenter />).type
);
const rights = components.filter(
(comp) => (comp as JSX.Element).type === (<HeaderRight />).type
);
return (
<header className="w-full flex items-center justify-between gap-x-1">
<div className="left">{lefts}</div>
<div className="center">{centers}</div>
<div className="right">{rights}</div>
</header>
);
};
const Header = Object.assign(HeaderContainer, {
Left: HeaderLeft,
Center: HeaderCenter,
Right: HeaderRight,
});
작성한 헤더 컴포넌트는 다음과 같이 불러올 수 있다.
앞서 작성한, HeaderLeft, HeaderCenter, HeaderRight 컴포넌트는 Header.Left, Header.Center, Header.Right 로 호출할 수 있다.
합성 컴포넌트로써 다음과 같은 요구사항에 대응할 수 있다.
만약 좌측에 있는 아이콘을 바꿔야 한다면 컴포넌트 내부를 수정하지 않고 아이콘 컴포넌트만 바꿔주면 된다.
좌측에 아이콘과 텍스트를 같이 표시해야 한다면 텍스트 표시용
Header.Left를 추가로 호출할 수 있다.
import Header from "./Header";
import LeftIcon from "./LeftIcon.svg";
const App = () => {
return (
<Header>
<Header.Left>
<LeftIcon />
</Header.Left>
<Header.Left>
<h3>정보</h3>
</Header.Left>
<Header.Center>
<h2>프로젝트 제목</h2>
</Header.Center>
<Header.Right>
<button>업로드</button>
</Header.Right>
</Header>
);
};
여러 개의 컴포넌트로 구성되는 합성 컴포넌트에서는 상태 관리를 위해 리액트 컨텍스트 API를 도입할 수 있다. 컨텍스트 API는 Props 없이 상태를 공유할 수 있게 도와준다.
컨텍스트 API로 작업한 리스트 컴포넌트이다. 선택된 텍스트와 텍스트를 클릭했을 때의 콜백 함수를 컨텍스트를 통해 타 컴포넌트로 전달할 수 있다.
ListItem 컴포넌트는 텍스트만을 Props으로 받아서 표시하는 역할을 한다. 동시에 컨텍스트로부터 콜백 함수를 받아서 사용자가 아이템을 클릭했을 때 콜백 함수를 호출할 수 있다.
import {
Children,
createContext,
MouseEvent,
PropsWithChildren,
useContext,
useState,
} from "react";
export interface ListContextType {
text?: string;
onItemClick?: (event: MouseEvent, text: string) => unknown;
}
const ListContext = createContext<ListContextType>(null!);
const ListHeader = (props: PropsWithChildren) => {
return <div className="flex items-center">{props.children}</div>;
};
const ListSelected = () => {
const { text } = useContext(ListContext);
return (
<div className="w-full flex items-center justify-center">
<h2>{text}</h2>
</div>
);
};
export interface ListItemProps {
text?: string;
}
const ListItem = (props: ListItemProps) => {
const { onItemClick } = useContext(ListContext);
const { text } = props;
return (
<button
className="w-full flex items-center justify-center"
onClick={(event) => {
if (text) {
onItemClick?.(event, text);
}
}}
>
<p>{text}</p>
</button>
);
};
interface ListContainerProps extends PropsWithChildren {
onItemClick?: (event: MouseEvent, text: string) => unknown;
}
const ListContainer = (props: ListContainerProps) => {
const { onItemClick } = props;
const [selected, setSelected] = useState<string>();
const components = Children.toArray(props.children);
const header = components.find(
(comp) => (comp as JSX.Element).type === (<ListHeader />).type
);
const text = components.find(
(comp) => (comp as JSX.Element).type === (<ListSelected />).type
);
const items = components.filter(
(comp) => (comp as JSX.Element).type === (<ListItem />).type
);
return (
<ListContext.Provider
value={{
text: selected,
onItemClick: (event, text) => {
setSelected(text);
onItemClick?.(event, text);
},
}}
>
<div className="flex flex-col w-full">
{header}
{text}
<div className="flex flex-col w-full gap-y-1">{items}</div>
</div>
</ListContext.Provider>
);
};
const List = Object.assign(ListContainer, {
Header: ListHeader,
Selected: ListSelected,
Item: ListItem,
});
export default List;
시행착오
합성 컴포넌트를 어떻게 작성할지 학습하기 위해 예제 코드를 읽어봤을 때는 왜 컨텍스트를 한 번만 선언하는지 이해하지 못했던 적이 있었다.
앞서 작성한 리스트 컴포넌트는 여러 컴포넌트에서 재사용할 수 있는데 컨텍스트가 하나만 선언되어있으면 컨텍스트의 값이 계속 덮어씌워지지 않을까 우려했던 적이 있었다.
리액트 공식문서를 보고 무엇을 잘못 알고 있었는지 알 수 있었다. 컨텍스트의 값을 제공하는 역할은 컨텍스트가 아니라 컨텍스트의 프로바이더 컴포넌트(Provider)이고, 프로바이더의 value 속성으로 값을 하위 컴포넌트로 전달할 수 있다.
하위 컴포넌트에서 호출되는 useContext는 UI 트리에서 가장 가까운 프로바이더의 값을 사용하게 된다. UI 트리에 프로바이더가 없으면 createContext 함수에 전달된 기본값을 사용하게 된다.
Comments
Join the discussionNo comments yet. Be the first to comment.
