이전 글에서 Context API 기반의 Compound Component를 구현하고, Select, Accordion, Tabs, ToggleGroup에 같은 공식을 적용했습니다. 이 글에서는 기본 패턴만으로 해결되지 않는 세 가지 한계와, 각각을 넘기 위한 기법을 다룹니다.
기본 Compound의 한계
Compound Component는 props 과다 문제를 해결했지만, 실무에서 사용하다 보면 새로운 벽에 부딪힙니다.
첫째, sub-component 안에서만 Context를 소비할 수 있기 때문에, component 바깥의 UI가 내부 state에 반응할 수 없습니다. 둘째, 사용자가 JSX를 어떤 순서로 작성하든 그대로 rendering되기 때문에, 정해진 layout을 강제할 방법이 없습니다. 셋째, 자식에게
index나 state를 자동으로 주입하려면 별도의 처리가 필요합니다.이 세 가지 한계를 해결하는 기법을 하나씩 살펴봅니다.
Render Props -- state를 바깥에서도 활용하기
기본 Compound에서 sub-component 안에서만 Context를 사용할 수 있다는 한계를 넘고 싶을 때 사용합니다.
children을 함수로 받아 내부 state를 외부에 노출하는 기법입니다.부모 component에서
children이 함수인지 분기하는 것이 핵심입니다.{typeof children === "function" ? children({ isOpen, toggle }) : children}
이를 통해 Compound component의 sub-component가 아닌 외부 UI도 내부 state에 반응할 수 있습니다. 아래 예시의
Disclosure는 열림/닫힘을 토글하는 Compound Component입니다.<Disclosure> {({ isOpen }) => ( <> {/* Disclosure 바깥 UI인데도 isOpen에 반응 */} <StatusBadge active={isOpen} /> <Disclosure.Button>토글</Disclosure.Button> <Disclosure.Panel>내용</Disclosure.Panel> </> )} </Disclosure>
children이 함수가 아닌 경우에는 기존 Compound 방식 그대로 동작하므로, 기존 사용 코드와의 호환성도 유지됩니다.Slot Pattern -- layout 순서 강제하기
사용자가 어떤 순서로 JSX를 작성하든, 정해진 layout을 유지해야 하는 경우에 사용합니다.
displayName으로 자식을 분류하고, 항상 고정된 순서로 rendering합니다.function Card({ children }) { const slots = {}; Children.forEach(children, (child) => { if (isValidElement(child)) { const name = child.type.displayName; if (name === "CardHeader") slots.CardHeader = child; if (name === "CardBody") slots.CardBody = child; if (name === "CardFooter") slots.CardFooter = child; } }); // 항상 Header -> Body -> Footer 순서로 rendering return ( <div> {slots.CardHeader} {slots.CardBody} {slots.CardFooter} </div> ); }
사용하는 쪽에서 Footer, Body, Header 순서로 작성해도 rendering 결과는 항상 Header, Body, Footer 순서입니다.
{/* Footer -> Body -> Header 순서로 작성해도... */} <Card> <Card.Footer>버튼</Card.Footer> <Card.Body>본문</Card.Body> <Card.Header>제목</Card.Header> </Card> {/* -> 항상 Header -> Body -> Footer로 rendering */}
design system에서 일관된 layout을 강제하면서도 사용 자유도를 유지하고 싶을 때 유용합니다.
Implicit Props (cloneElement) -- legacy 참고용
cloneElement를 사용해 자식에게 index나 state를 자동으로 주입하는 기법입니다.{Children.map(children, (child, index) => cloneElement(child, { index, isActive: index === activeStep }) )}
사용자는
<Step>에 index를 전달하지 않았는데도, 각 Step이 자신의 위치를 알게 됩니다. 다만 React 공식 문서에서 cloneElement는 legacy로 분류됩니다. 새 코드에서는 Context 방식이 권장되지만, 기존 library(Reach UI, 구버전 Radix 등)에서 여전히 사용되므로 코드를 읽을 수 있어야 합니다.cloneElementlets you create a new React element using another element as a starting point. [...] Alternatives: Pass data with a render prop / Pass data through context -- React 공식 문서
기법 선택 기준
대부분의 경우 기본 Compound(Context만)로 충분합니다. 내부 state를 바깥에서도 활용해야 하면 Render Props를, layout 순서를 강제해야 하면 Slot Pattern을 추가합니다.
cloneElement는 legacy 코드를 읽을 때만 참고합니다.상황 | 권장 기법 |
대부분의 경우 | 기본 Compound (Context만) |
내부 state를 바깥에서도 활용 | Render Props 추가 |
Layout 순서를 강제 | Slot Pattern |
Legacy 코드를 읽어야 할 때 | cloneElement 이해 |
마무리
이 글에서 다룬 세 가지 기법은 모두 Compound Component의 유연성을 높이는 방법입니다. 하지만 이 기법들로도 해결되지 않는 마지막 한계가 남아 있습니다. style이 component 안에 고정되어 있다는 문제입니다. 다음 글에서는 이 한계를 넘는 Headless 패턴과, Compound와 Headless를 결합한 최종 형태인 Radix UI의 내부 설계를 살펴봅니다.
kyu-log