🧩 Design System5. Composition (Slot·Polymorphism)Compound Components — 부모-자식이 context로 상태 공유

Compound Components — 부모-자식이 context로 상태 공유

이 문서가 답하는 질문: <Tabs.List><Tabs.Trigger>가 서로 같은 activeTab을 알아야 한다. props drilling 없이 어떻게 부모-자식 사이에 상태를 공유하는가? 한 줄 답 (Pyramid Top): Compound Components는 부모가 React Context로 상태를 발행하고, 자식들이 같은 Context를 구독하여 자율적으로 동작하는 패턴이다. 사용자는 <Tabs><Tabs.List>...</Tabs>처럼 자연어에 가까운 JSX를 쓰고, 라이브러리는 props를 폭발시키지 않는다.


Why — 왜 compound components인가

Tabs를 props 하나로 만들면 어떻게 생겼을까?

// 안티패턴: props 폭발
<Tabs
  tabs={[
    { id: 'a', label: 'A', content: <A /> },
    { id: 'b', label: 'B', content: <B />, disabled: true },
  ]}
  defaultActive="a"
  onActiveChange={...}
  orientation="horizontal"
  triggerVariant="underline"
  panelClassName="..."
  listClassName="..."
  triggerClassName="..."
/>

→ Tabs 컴포넌트가 Tab 데이터의 구조까지 강제한다. 사용자는 자신의 데이터 모양에 맞춰 변환해야 한다. 또한 각 Tab의 마크업 커스터마이즈가 어렵다 (icon? badge? disabled?).

Compound 패턴은 같은 기능을 이렇게 표현한다:

<Tabs defaultValue="a">
  <Tabs.List>
    <Tabs.Trigger value="a">A</Tabs.Trigger>
    <Tabs.Trigger value="b" disabled>B <Badge>3</Badge></Tabs.Trigger>
  </Tabs.List>
  <Tabs.Panel value="a"><A /></Tabs.Panel>
  <Tabs.Panel value="b"><B /></Tabs.Panel>
</Tabs>
풀려는 문제props-only 방식compound 방식
자식 구조 자유도라이브러리가 정의사용자가 정의
자식 사이 통신부모 props로 모두 전달Context 자동 공유
새 자식 추가라이브러리 수정외부에서 추가 가능
JSX 가독성flat, 데이터 같음nested, 마크업 같음
props 수10+2-3

How — Context + dot notation

4단계 동작 흐름:

  1. 부모 <Tabs>useState[value, setValue] 보유.
  2. 그 상태를 TabsContext.Provider value={{ value, onValueChange }}로 발행.
  3. 자식들 (Trigger, Panel)이 useContext(TabsContext)로 구독.
  4. 자식이 자신의 value prop과 context의 value를 비교해 자율적으로 active 여부 결정.

What — 완전한 구현

최소 Tabs 구현

// tabs.tsx
import { createContext, useContext, useState, type ReactNode } from 'react'
 
// 1) Context
type TabsCtx = {
  value: string
  onValueChange: (v: string) => void
}
const TabsContext = createContext<TabsCtx | null>(null)
 
const useTabsContext = () => {
  const ctx = useContext(TabsContext)
  if (!ctx) throw new Error('Tabs.* must be used inside <Tabs>')
  return ctx
}
 
// 2) Root
type TabsProps = {
  defaultValue: string
  value?: string
  onValueChange?: (v: string) => void
  children: ReactNode
}
export function Tabs({ defaultValue, value: controlledValue, onValueChange, children }: TabsProps) {
  const [uncontrolled, setUncontrolled] = useState(defaultValue)
  const value = controlledValue ?? uncontrolled
  const handle = (v: string) => {
    if (controlledValue === undefined) setUncontrolled(v)
    onValueChange?.(v)
  }
  return (
    <TabsContext.Provider value={{ value, onValueChange: handle }}>
      <div data-tabs-root>{children}</div>
    </TabsContext.Provider>
  )
}
 
// 3) List
Tabs.List = function TabsList({ children }: { children: ReactNode }) {
  return (
    <div role="tablist" className={tabs.list()}>
      {children}
    </div>
  )
}
 
// 4) Trigger
Tabs.Trigger = function TabsTrigger({
  value,
  children,
  disabled,
}: {
  value: string
  children: ReactNode
  disabled?: boolean
}) {
  const ctx = useTabsContext()
  const isActive = ctx.value === value
  return (
    <button
      role="tab"
      aria-selected={isActive}
      data-state={isActive ? 'active' : 'inactive'}
      disabled={disabled}
      onClick={() => ctx.onValueChange(value)}
      className={tabs.trigger()}
    >
      {children}
    </button>
  )
}
 
// 5) Panel
Tabs.Panel = function TabsPanel({
  value,
  children,
}: {
  value: string
  children: ReactNode
}) {
  const ctx = useTabsContext()
  if (ctx.value !== value) return null
  return (
    <div role="tabpanel" data-state="active" className={tabs.panel()}>
      {children}
    </div>
  )
}

사용 예

<Tabs defaultValue="general">
  <Tabs.List>
    <Tabs.Trigger value="general">일반</Tabs.Trigger>
    <Tabs.Trigger value="security">보안</Tabs.Trigger>
    <Tabs.Trigger value="billing" disabled>결제</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Panel value="general"><GeneralSettings /></Tabs.Panel>
  <Tabs.Panel value="security"><SecuritySettings /></Tabs.Panel>
  <Tabs.Panel value="billing"><BillingSettings /></Tabs.Panel>
</Tabs>

두 가지 dot notation 패턴

패턴코드장점단점
A. static 속성Tabs.List = function...tree-shaking 살아남음, 분리 정의 쉬움TS 추론이 살짝 약함
B. namespace exportexport const Tabs = { Root, List, Trigger }<Tabs.Root>명시적, Radix 스타일<Tabs> 단축형 없음

Radix Primitives는 B 스타일<Tabs.Root>, <Tabs.List>, … 명시적. shadcn/ui는 flat exportimport { Tabs, TabsList, TabsTrigger, TabsContent } from "..." — dot notation 자체를 안 씀. 트리쉐이킹과 코드 점프가 깔끔하기 때문.

Controlled vs Uncontrolled

// Uncontrolled - 라이브러리가 상태 관리
<Tabs defaultValue="a">...</Tabs>
 
// Controlled - 사용자가 상태 관리
const [tab, setTab] = useState('a')
<Tabs value={tab} onValueChange={setTab}>...</Tabs>

위 구현은 둘 다 지원한다. controlled prop이 들어오면 internal state를 무시하는 표준 패턴.

Context 외부 export

// 라이브러리 사용자가 *자기만의* Trigger를 만들고 싶을 때
export { TabsContext, useTabsContext }
 
// 사용자 코드
function CustomTrigger({ value }) {
  const ctx = useTabsContext()
  return <button onClick={() => ctx.onValueChange(value)}>...</button>
}

이걸 안 하면 라이브러리는 닫힌 상자가 된다. Context+Hook을 함께 export하라.

slot recipe + compound = 좋은 합성

import { tabs as tabsRecipe } from 'styled-system/recipes'
// tabs는 slot recipe: { list, trigger, panel }
 
// Root에서 recipe 호출 → slot 함수를 Context에 주입
type TabsCtx = TabsState & { styles: ReturnType<typeof tabsRecipe> }
 
export function Tabs({ variant, size, ...rest }) {
  const styles = tabsRecipe({ variant, size })
  // ...
  return <TabsContext.Provider value={{ ...state, styles }}>...</TabsContext.Provider>
}
 
Tabs.Trigger = function ({ value, children }) {
  const { styles, value: active, onValueChange } = useTabsContext()
  return <button className={styles.trigger} onClick={...}>{children}</button>
}

→ 자식 컴포넌트가 매번 recipe를 호출하지 않음. slot recipe의 prop drift 함정 해소.


What-if — 잘못 쓰면 어떻게 깨지는가

  • 함정 1 — Context 없이 자식 사용

    • 증상: <Tabs.Trigger value="a" />만 따로 쓰면 useContextnull을 반환 → crash.
    • 대응: useTabsContext()에서 명시적 에러 throw ('Tabs.* must be used inside <Tabs>').
  • 함정 2 — Children을 직접 순회하며 cloneElement

    • 증상: 옛 패턴은 React.Children.map으로 자식에 props 주입. 자식이 <Fragment>이거나 조건부면 동작 안 함.
    • 대응: Context로 통신. 절대 Children.map으로 prop 주입 마라.
  • 함정 3 — 강제된 자식 구조

    • 증상: Tabs.List 안에서만 Tabs.Trigger를 허용하고 싶다는 검증 코드를 작성.
    • 대응: 런타임 검증보다는 허용하라. 사용자가 wrapper <div>로 감싸야 할 때가 있다. role+aria만 정확하면 충분.
  • 함정 4 — Context 값을 매 렌더마다 새 객체로

    • 증상: value={{ value, onChange }}가 매번 새 ref → 자식 전체 리렌더.
    • 대응: useMemo(() => ({ value, onChange }), [value])로 메모이즈. 큰 트리에서는 측정 가능.
  • 함정 5 — Context 두 번 nesting (Tabs 안의 Tabs)

    • 증상: 내부 Tabs가 외부 Tabs의 context를 덮지 못함 (Provider가 nest되어야 함).
    • 대응: 위 구현처럼 Root마다 Provider를 두면 자연스럽게 nest됨. 추가 작업 없음.
  • 함정 6 — 키보드 내비 잊음

    • 증상: Arrow 키로 Tab 이동, Home/End로 처음/끝 — 직접 구현 안 함.
    • 대응: 직접 만들지 말고 Radix의 Tabs를 wrapping 해라. headless 라이브러리가 키보드/포커스 관리를 다 해준다 (다음 챕터).

Insight — 패턴의 출처와 시대

Compound Components라는 용어는 2017~2018년 Kent C. Dodds와 Ryan Florence가 컨퍼런스 강연으로 대중화시켰다. 당시 React는 *“props가 모든 것”*이라는 패러다임이 강했고, Children.map + cloneElement로 자식에 props 주입하는 방식이 유행했다.

2018년 React 16.3의 Context API 정식 버전이 나오면서 Children.map 시대가 끝나고 Provider/Consumer 시대가 열렸다. Compound Components는 그 흐름의 대표 수혜자다.

현재 사실상 모든 headless 라이브러리는 compound + asChild + context의 조합이다.

Radix Primitives의 Dialog, Tabs, Accordion, Menubar — 모두 같은 골격이다. 우리가 직접 만들 때도 그 골격을 따르는 게 안전하다. shadcn/ui는 한 발 더 나아가 dot notation을 버리고 flat export(Tabs, TabsList, TabsTrigger)로 갔는데, 이유는 tree-shaking과 IDE의 go to definition 정확도 때문이다.


요약

  • Compound Components는 Context로 부모-자식이 상태 공유하는 패턴.
  • 사용자 JSX는 자연스러운 마크업처럼 읽힌다 — <Tabs><Tabs.List>....
  • Controlled/Uncontrolled 둘 다 지원하기. value가 들어오면 internal state 무시.
  • Context와 hook (useTabsContext)을 함께 export하여 외부 확장 허용.
  • Children.map + cloneElement로 props 주입은 옛날 방식. Context로 통신.
  • 키보드/포커스 관리는 직접 만들지 말고 headless 라이브러리에 위임 (다음 챕터).