최근, 디자인 시스템을 제작하는 사이드 프로젝트에 참가하게 되었다. 🕵️
이 때문에 재사용 가능한 컴포넌트를 어떻게 구현해야할지 고민에 고민을 하고 있는 중이다.🤔
고민을 거듭 하던 중 머리 속 번뜩이는 용어 하나
합성 컴포넌트 패턴
!
합성 컴포넌트 패턴을 사용하면 재사용 가능한 컴포넌트를 만들기에 편리하다는 이야기가 뇌의 어딘가에 저장되어 있었다. (어디선가 들은 기억이 ... 🧐)
그래서 이번 기회에 합성 컴포넌트 패턴
에 대해서 자세히 알아보고 글로 작성하고자 해본다.
(그런데 이제 chat gpt를 추가한)
합성 컴포넌트에 대한 힌트를 얻기 위해서 chatgpt에 질문을 해보았다.
합성 컴포넌트 패턴이 뭐야?
우리의 chat gpt는 꽤나 길고 상세한 답변을 주었다.
한국어로 번역해서 내용을 요약하자면 아래와 같다.
정의
복잡한 구성 요소를 만들기 위해 결합할 수 있는 작고 재사용 가능한 구성 요소 집합을 만드는 디자인 패턴
특징
chat gpt의 설명과 예전에 들은 합성 컴포넌트에 대한 지식을 조합하여 합성 컴포넌트의 특징에 대해서 고민해보았다.
가장 먼저 떠오른 생각은
"상황이 optional
인 상황에서 유용하겠다" 라는 생각이였다.
chat gpt에 따르면 합성 컴포넌트는여러 개의 기능이나 측면이 있는 컴포넌트
를 하나 하나의 함수로 분리하고 이를 다시 조합하여 사용하는 컴포넌트이다.
즉, 아래와 같이 특정 기능을 담당하는 A,B 각각의 컴포넌트를 가지고
A, B로 이루어지거나 or A로만 이루어지거나 or B로만 이루어지도록 구성하여 새로운 컴포넌트를 만들 수 있을 것이다.
<Container>
<AItem/>
<BItem/>
</Container>
or
<Container>
<AItem/>
<AItem/>
</Container>
or
<Container>
<BItem/>
<BItem/>
</Container>
두 번 째로는
나의 기억 속에 있는 여러 개의 컴포넌트들 중
어떤 컴포넌트가 합성 컴포넌트 패턴에 어울릴 것인지 고민해보았다. 🤔👀
여러 개의 컴포넌트들 중 2가지 컴포넌트가 떠올랐다.
첫 번째는 NavBar이다.
공식이라는 프로젝트에서
로그인 여부에 따라
로그인 되어 있을 경우 => 유저의 프로필 이미지
로그인 안 되어 있을 경우 => 기본 icon
를 보여주는 NavBar
컴포넌트를 구현한 경험이 있다.
그 때에
유저의 프로필 이미지를 보여주는 UserProfile
컴포넌트가 있었는데 그곳 내부에서 로그인 여부에 따라 icon을 보여주거나 프로필 이미지를 보여주도록 구현하였다.
이때의 구현 방법은 로직이 컴포넌트 내부에 숨겨져 있어서 다시 유지 보수 하기에는 어려움이 있을 것 같다고 생각 하고 있었다.
그래서 합성 컴포넌트를 적용하여
다른 곳에서도 사용되는 UserProfile
컴포넌트는 로그인 되었을 때에만 사용되는 컴포넌트로 두고 NavBar에서 종속된 UserIcon컴포넌트는 아래와 같이 구현하면 더 효과적일 것 같다는 생각이 들었다.
<NavBar>
<NavBar.Write />
{
isLogin ? <UserProfile /> : <NavBar.UserIcon />
}
<NavBar.Menu />
</NavBar>
두 번째는 Select이다.
select가 역할을 하는 컴포넌트를 구현할 때에는 꽤나 많은 폼이 들어간다.
만약, 재사용성을 고려하여 경우에 따라서 select에 들어갈 option들이 달라진다면 그 복잡성은 더 올라간다.
그렇기 때문에 페이지에 따라서
<Select>
<BItem />
<AItem />
</Select>
or
<Select>
<AItem />
<BItem />
</Select>
이렇게 option의 순서나 종류를 바꿔야 할 때에 합성 컴포넌트 패턴을 사용하면 효과적일 것 같다.
나의 이런 생각들이 맞는지 확인해보기 위해 이번에는 chat gpt에게 자세한 예시를 보여달라 요청했다.
예시를 묻자
chat gpt는 DropDown의 코드를 알려주었다.
chat gpt가 말하는 DropDown에 들어갈 기능은 3가지 이다.
꽤나 긴 코드이지만 chat gpt가 설명해준 그대로 복사해본다
function Dropdown({ children }) {
const [isOpen, setIsOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState(null);
function toggleMenu() {
setIsOpen(!isOpen);
}
function handleItemClick(itemValue) {
setSelectedItem(itemValue);
setIsOpen(false);
}
return (
<div className="dropdown">
<button onClick={toggleMenu}>Toggle Menu</button>
{isOpen && (
<ul className="dropdown-menu">
{React.Children.map(children, (child) => {
// check if child is a DropdownItem component
if (child.type.name === 'DropdownItem') {
// clone the DropdownItem component and pass additional props
return React.cloneElement(child, {
onItemClick: handleItemClick,
isSelected: selectedItem === child.props.value,
});
}
})}
</ul>
)}
{selectedItem && (
<div className="selected-item">
Selected Item: {selectedItem}
</div>
)}
</div>
);
}
function DropdownItem({ value, children, onItemClick, isSelected }) {
function handleClick() {
onItemClick(value);
}
return (
<li onClick={handleClick} className={isSelected ? 'selected' : ''}>
{children}
</li>
);
}
// usage example
<Dropdown>
<DropdownItem value="option1">Option 1</DropdownItem>
<DropdownItem value="option2">Option 2</DropdownItem>
<DropdownItem value="option3">Option 3</DropdownItem>
</Dropdown>
그리고 이것을 그대로 코드를 실행시켜보았다
.
.
.
???
조금은 엉성하다😅
스타일링이 제대로 되어 있지 않아 그런거라는 희망과 함께
사용된 함수들에 대해서 살펴보고 코드를 수정해보자.
코드를 본격적으로 수정하기 전, chat gpt가 답해준 코드에서 사용된 함수들에 대해서 알아보고 가자.
코드를 살펴보다 보면 React.Children.map이라는 다소 익숙하지 않은 함수가 사용되고 있는 것을 볼 수 있다.
{React.Children.map(children, (child) =>
이에 대해서 React beta의 설명은 아래와 같다.
Children.map(children, fn, thisArg?)
chat gpt는 해당 api를
children의 형태로 받은 각각의 component들에 접근하여 함수를 제공해주는 데에 사용하고 있다.
Children.map 내부의 child에 부모 컴포넌트에서 구현한 함수를 props로 넘겨주기 위해 React.cloneElemt
가 사용되고 있다.
return React.cloneElement(child, {
onItemClick: handleItemClick,
isSelected: selectedItem === child.props.value,
});
위와 마찬 가지로 React beta의 설명을 찾아보았을 때는 아래와 같은 답을 주는 것을 알 수 있다.
cloneElement(element, props, ...children)
즉, Element를 복사하여 복사한 element에 새로운 props들을 추가하여 넘기는데 사용할 수 있는 api이다.
그렇지만, React docs beta에서는 해당 api 사용을 아래의 3가지 이유 때문에 renderItem
, Context API
, Custom Hook
으로 대체하여 사용하기를 권장하고 있다.
리액트에서 React.cloneElement
사용을 권장하지 않고 있으니 chat gpt가 제시해준 코드에서
Context API
로 교체 +
typescript을 적용하여 코드를 수정해보자.
React.cloneElement를 통해서 컴포넌트를 복사하여 props로
DropDown
컴포넌트 내에서 선언된
함수 handleItemClick
+ 상태값 selectedItem
변수 값을
props로 넘겼던 것에서
Context api
를 통해서 공유하기 위해 createContext를 해주자
const DropDownContext = createContext<{
handleItemClick: (valueItem: string) => void;
selectedItem: string;
} | null>(null);
function Container({
children,
}: {
children:
| ReactElement<PropsWithChildren<DropDownItemProps>>[]
| ReactElement<PropsWithChildren<DropDownItemProps>>;
}) {
const [isOpen, setIsOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<string>('');
function toggleMenu() {
setIsOpen(!isOpen);
}
function handleItemClick(itemValue: string) {
setSelectedItem(itemValue);
setIsOpen(false);
}
return (
<Box>
<ToggleButton onClick={toggleMenu}>메뉴 열기</ToggleButton>
{isOpen && (
<ul>
<DropDownContext.Provider value={{ handleItemClick, selectedItem }}>
{children}
</DropDownContext.Provider>
</ul>
)}
{selectedItem && <div>Selected Item: {selectedItem}</div>}
</Box>
);
}
DropDown의 children으로 하나의 원소만 올 수 도 있으니
children의 타입을
ReactElement<PropsWithChildren<DropDownItemProps>>[] | ReactElement<PropsWithChildren<DropDownItemProps>>
으로 정하게 되었다.
또한 기존에 React.cloneElement
를 통해서 넘기고 있던 함수들을
Context Provider
의 value로 넘겨주어 children에서 사용할 수 있도록 하였다.
interface DropDownItemProps {
value: string;
}
function Item({ value, children }: PropsWithChildren<DropDownItemProps>) {
const context = useContext(DropDownContext);
if (!context) {
throw new Error('context api 사용 불가');
}
const { handleItemClick, selectedItem } = context;
function handleClick() {
handleItemClick(value);
}
return (
<LiItem onClick={handleClick} isSelected={selectedItem === value}>
{children}
</LiItem>
);
}
이제, DropDownItem은 props로 item에 보여줄 value만을 받지 않는다
그대신 ContextAPI 를 통해서 함수 값을 받아오고 있기 때문에 context 여부에 따른 예외 처리를 해주어야 한다.
const context = useContext(DropDownContext);
if (!context) {
throw new Error('context api 사용 불가');
}
내보낼 때에는 Object.assign을 활용하여 묶어서 사용할 수 있도록 한다.
const DropDown = Object.assign(Container, {
item: Item,
});
export default DropDown;
<DropDown>
<DropDown.item value="option1">option 1</DropDown.item>
<DropDown.item value="option2">option 2</DropDown.item>
<DropDown.item value="option3">option 3</DropDown.item>
</DropDown>
사용하는 곳에서는 각각의 컴포넌트에 value
값을 다르게 주어 구현이 가능해진다
이제 이 코드에 아주 조금의 스타일을 추가하여 결과물을 살펴보면 아래와 같다.
이번 기회에 처음으로 합성 컴포넌트 패턴
을 사용하여 컴포넌트를 구현해보았다.
구현 과정이 Context API
를 사용하거나 컴포넌트를 여러개로 쪼개놓기 때문에 구현하는 데에 꽤나 복잡하다고 생각이 들었다.
하지만 선언적
이라는 리액트의 철학을 다시 한번 생각해보았을 때
해당 철학이 잘 녹여져 있는 패턴이라고 생각이 들었다.
사용 되는 곳에서 선언적으로 (한 단계 더 들어갈 필요 없이) 해당 컴포넌트에 어떤 구성 요소들이 있는지 확인할 수 있으니 말이다.
나의 가정인 NavBar에서 합성 컴포넌트 패턴 사용이 적절한지는 사실 아직 애매하긴 하다.
isLogin여부를 NavBar에서 분기처리해도 되지 않는가? 라고 말한다면 딱히 반박할 수는 없지만 그래도 isLogin을 Context api와 같은 전역 상태 관리 도구에서 관리하고 있다면 효과적인 패턴이 될 수 있을 것 같다는 생각이 들었다.
디자인 시스템을 해나가면서 오늘 이렇게 배운 합성 컴포넌트에 대해 좀 더 탐구하여 블로그 글을 추후 포스팅하겠다