thumbnail

Compound 패턴을 사용한 깔끔한 Tab 컴포넌트 구현기

생성일2024. 10. 15.
태그
작성자지한솔

notion image
컴포넌트를 구현할땐 다양한 패턴을 사용할 수 있습니다. 이번 블로그를 만들면서 정한 패턴중 Compound 패턴을 사용해보기로 결정했고, 여러 방면으로 사용성을 높여보고 있습니다!
 
그 중 이번에 Tab 컴포넌트는 만들면서 어떻게 Compound 패턴을 적용했는지 공유하고자 합니다! (완성된 Tab 컴포넌트 보러가기)

Compound Pattern

컴포넌트를 구현하는 패턴엔 여러가지 패턴이 있습니다. Container Component, Composition Component, Compound Component 등이 있죠. 서로가 연관이 되어있을 수도 있는 구조입니다.
Compound Component는 복합 구성 요소 패턴 이라고 합니다. 주로, Select, Radio, CheckBox 등의 기능을 구현할때 적합하게 사용할 수 있습니다.
<Toggle> <Toggle.Item></Toggle.Item> <Toggle.Item></Toggle.Item> </Radio>
형태는 위처럼 dot 을 찍어서 사용하는 구조입니다. 이렇게 구현하는 이유는 예를들어 Toggle 이 있을때, 이 Toggle은 item을 보여주는 isOpen, setIsOpen을 해야할 수 있습니다. 간단하게
<Toggle isOpen={isOpen} onClick={() => setIsOpen(prev => !prev)}> <div></div> <div></div> </Radio>
위 처럼 말이죠.
하지만, 꼭 isOpen과 onClick을 외부에서 주입해주어야 할까요? isOpen을 토대로 핸들링하는 로직이 없다면 굳이 외부에 위임할 필요가 없죠.
이런 상황일때 Compound Component를 활용할 수 있습니다. Toggle안에 isOpen, onClick 의 액션을 위임해주고, 외부에서는 제공되는 컴포넌트 형식대로만 사용하면 되는거죠.
우리가 구현할 Tab도 외부에서 핸들링이 필요 없도록 구현할 것입니다.

컴포넌트로 만들기 전에, 코드로 구현하기.

Tab 컴포넌트를 Compound Component로 하기 이전에, 구현이 되는 코드 셋을 미리 구현해보도록 하겠습니다.
// tabs 변수 생성 const tabs = [{ label: "숫자 생성기" }, { label: "로또 번호 생성기" }];
tab을 구성할 label을 미리 생성합니다.
function () { return ( <> {/* Tab 구성요소 */} <div className="w-full flex"> <ul className="border rounded-lg flex"> {tabs.map((tab) => ( <li key={tab.label} className={...} onClick={() => setSelectedTab(tab.label)} > {tab.label} </li> ))} </ul> </div> {/* 탭별로 보여줄 랜더링 컴포넌트 */} <> {selectedTab === "숫자 생성기" && ( <form className="flex flex-col custom:flex-row w-full gap-4 mb-4" onChange={...} onSubmit={...} > <Input name={"count"} className={"w-full"} placeholder={"숫자 개수"} defaultValue={10} /> <Input name={"min"} className={"w-full"} placeholder={"최소값"} defaultValue={0} /> <Input name={"max"} className={"w-full"} placeholder={"최대값"} defaultValue={10} /> <Button.Primary disabled={isDisabled} type={"submit"}> 생성하기 </Button.Primary> </form> )} {selectedTab === "로또 번호 생성기" && ( <form className="flex w-full gap-4 mb-4" onChange={...} onSubmit={...} > <Input name={"lotto"} className={"w-full"} placeholder={"몇 게임"} /> <Button.Primary disabled={isDisabled} type={"submit"}> 생성하기 </Button.Primary> </form> )} </> </> ) }
위에서 보면 컴포넌트는 selectedTab 이라는 변수를 가지고 있습니다. 이 변수를 통해서 내부에 tab의 상태를 관리합니다.
tab을 누르면 onClick 에 onClick={() => setSelectedTab(tab.label)} 를 통해서 컴포넌트에 선택된 탭이 무엇인지 전달합니다.
그리고 selectedTab 에 따라 조건부 랜더링을 진행합니다.

Compound Component로 변경해보기.

이제 Compound Component로 변경을 진행하려고 합니다.
우리는 불필요한 tabs 변수를 없앨겁니다. Tab 컴포넌트의 자식 컴포넌트들에 name 규칙을 하나 지정해서, name 을 tabs 변수로 사용할 예정입니다.
// Tab.tsx type ItemElement = ReactElement<ComponentProps<typeof Tab.Item>>; interface Props { children: ItemElement | ItemElement[]; initializeValue?: string; onClick?: (value: string) => void; }
Tab은 기본값으로 Tab.Item을 받아야 하고, 초기 value값, onClick 값을 받을 수 있습니다. 그럼 우리는 children을 토대로 initializeValue 가 있다면 해당 initializeValue가 children의 몇번째 Element인지, 렌더링할 아이템은 무엇인지를 파악해야합니다.
export default function Tab({ initializeValue, children, onClick }: Props) { // children을 돌아서, name을 뽑아 tab을 구성하는 구성요소를 추출합니다. const tabs = Children.map(children, (child) => { return child.props.name; }) as string[]; // children 중에 어떤 값이 check되어있는 것인지 index를 추출 const checkedIndex = initializeValue ? Children.map(children, (child) => { return child.props; }).findIndex((p) => p.name === initializeValue) : 0; // 선택된 tab을 저장 const [selectedTab, setSelectedTab] = useState(tabs[checkedIndex]); // render할 자식 요소를 선정 const renderChildren = Children.map(children, (child) => { return child; }).find((v) => v.props.name === selectedTab); // tab을 눌렀을대 수행할 함수 const handleTabClick = (tab: string) => { setSelectedTab(tab); onClick?.(tab); }; return ( // ... ) }
React에서 제공하는 Children 를 사용해서 내부에 필요한 변수들을 전부 모아보았습니다. 이로써 우리는 tabs와 checkedIndex, renderChildren를 구할 수 있습니다.

Tab의 구성요소 구현하기

Tab은 우리가 위에서 구현했고, 이제는 Tab에 들어갈 Tab.Item을 구현할 차례입니다. 우리가 꼭 필요로 하는 구성요소가 하나 있습니다.
그것은 name props 입니다. Tab.Item은 무조건 name을 props로 받아야합니다.
interface TabItemProps extends ComponentProps<"div"> { name: string; children: React.ReactNode; } Tab.Item = function ({ name, children, ...rest }: TabItemProps) { return ( <div {...rest} id={name}> {children} </div> ); };
이렇게 구현할 수 있습니다. Tab.Item은 name을 받고, 기본적으로 div컴포넌트의 props를 받을 수 있게 되어있습니다.
그럼 이제 Tab컴포넌트는 Tab.Item에 들어오는 name값을 토대로 자동으로 내부 로직을 동작시킬 것입니다.
function () { return ( <> <form className="w-full mb-4" onChange={onChangeHandler} onSubmit={onSubmitHandler} > <Tab onClick={() => setRandomNumbers([])}> <Tab.Item name={"숫자 생성기"}> <div className="flex flex-col custom:flex-row w-full gap-4 mb-4"> <Input name={"count"} className={"w-full"} placeholder={"숫자 개수"} defaultValue={10} /> <Input name={"min"} className={"w-full"} placeholder={"최소값"} defaultValue={0} /> <Input name={"max"} className={"w-full"} placeholder={"최대값"} defaultValue={10} /> </div> </Tab.Item> <Tab.Item name={"로또 번호 생성기"}> <Input name={"lotto"} className={"w-full mb-4"} placeholder={"몇 게임"} defaultValue={1} /> </Tab.Item> </Tab> <div className={"w-full text-right"}> <Button.Primary className={"w-full custom:w-fit ml-auto"} disabled={isDisabled} type={"submit"} > 생성하기 </Button.Primary> </div> </form> </> ) }
우리는 이제 위에처럼 구현할 수 있습니다. Tab컴포넌트 안에 Tab.Item을 활용해서 불필요한 로직들을 제거햇습니다.
우리는 많은 코드 조각을 세이브 시켰고, 코드는 더 간결해지고, 유지보수는 더 쉬워졌을 것입니다.
 
어떤가요? Compound Component Pattern도 정말 유용하게 사용할 수 있습니다. 코드의 일관성을 제공하고, 더 간결하게끔 제공합니다.
Tab말고도, 모노레포 블로그를 구현할때 Compound Component Pattern을 자주 사용할 예정입니다! 깃허브와 블로그 많이 많이 방문해주세요!!
 
언제나 리뷰는 환영입니다! :D