합성 컴포넌트 패턴에서 컨텍스트 Api
합성 컴포넌트(Compound components) 패턴에서 컨텍스트 API로 상태를 공유할 수 있습니다.
합성 컴포넌트
합성 컴포넌트 패턴은 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 함수에 전달된 기본값을 사용하게 된다.
