---
id: wiki-2026-0508-compound-components
title: Compound Components
category: 10_Wiki/Topics
status: verified
canonical_id: self
aliases: [Compound Component Pattern, React Compound Components]
duplicate_of: none
source_trust_level: A
confidence_score: 0.95
verification_status: applied
tags: [react, patterns, components, composition, context]
raw_sources: []
last_reinforced: 2026-05-10
github_commit: pending
tech_stack:
  language: typescript
  framework: react
---

# Compound Components

## 매 한 줄
> **"매 parent 가 매 child 들과 implicit state share — 매 expressive composable API."**. Compound Component Pattern은 React (그리고 Vue/Solid) 에서 `<Tabs><Tab/><Tab.Panel/></Tabs>` 류의 declarative API 를 만드는 표준. Radix UI, Headless UI, Ariakit, shadcn/ui 의 dominant pattern. 2026년 React 19 + Server Components 시대에도 그대로 valid.

## 매 핵심

### 매 problem solved
- 단일 monolithic component 의 props explosion (`<Tabs items={...} renderTab={...} ...>`).
- 매 caller 가 매 children 의 layout/order 를 control 의 X.
- Slot composition 의 부재.

### 매 solution
- Parent 가 Context 로 shared state 의 publish.
- Children 이 Context 를 consume — explicit prop drilling 의 X.
- Children 의 component 가 namespace pattern (`Tabs.Tab`) 또는 separate exports.

### 매 patterns
- **React Context based** (most common 2026).
- **`React.Children` cloning** (legacy, fragile with refs/server components).
- **State reducer** (advanced — caller customizes reducer).
- **`asChild` slot** (Radix — wraps user element).

### 매 응용
1. Tabs, Accordion, Disclosure.
2. Menu, Dropdown, Combobox.
3. Dialog, Drawer, Popover.
4. Form fields with label/error/description.
5. DataTable column children.

## 💻 패턴

### Context-based Tabs
```typescript
import { createContext, useContext, useId, useState } from 'react';

type TabsContextValue = {
  activeId: string;
  setActiveId: (id: string) => void;
  baseId: string;
};
const TabsContext = createContext<TabsContextValue | null>(null);

function useTabs() {
  const ctx = useContext(TabsContext);
  if (!ctx) throw new Error('Tabs.* must be used inside <Tabs>');
  return ctx;
}

export function Tabs({
  defaultValue,
  children,
}: { defaultValue: string; children: React.ReactNode }) {
  const [activeId, setActiveId] = useState(defaultValue);
  const baseId = useId();
  return (
    <TabsContext.Provider value={{ activeId, setActiveId, baseId }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

function List({ children }: { children: React.ReactNode }) {
  return <div role="tablist" className="flex gap-2">{children}</div>;
}

function Trigger({ value, children }: { value: string; children: React.ReactNode }) {
  const { activeId, setActiveId, baseId } = useTabs();
  const selected = activeId === value;
  return (
    <button
      role="tab"
      id={`${baseId}-trigger-${value}`}
      aria-selected={selected}
      aria-controls={`${baseId}-panel-${value}`}
      tabIndex={selected ? 0 : -1}
      onClick={() => setActiveId(value)}
      className={selected ? 'tab tab-active' : 'tab'}
    >
      {children}
    </button>
  );
}

function Panel({ value, children }: { value: string; children: React.ReactNode }) {
  const { activeId, baseId } = useTabs();
  if (activeId !== value) return null;
  return (
    <div
      role="tabpanel"
      id={`${baseId}-panel-${value}`}
      aria-labelledby={`${baseId}-trigger-${value}`}
    >
      {children}
    </div>
  );
}

Tabs.List = List;
Tabs.Trigger = Trigger;
Tabs.Panel = Panel;
```

### Usage
```tsx
<Tabs defaultValue="overview">
  <Tabs.List>
    <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
    <Tabs.Trigger value="settings">Settings</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Panel value="overview"><OverviewView /></Tabs.Panel>
  <Tabs.Panel value="settings"><SettingsView /></Tabs.Panel>
</Tabs>
```

### Controlled variant
```typescript
type Props = {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  children: React.ReactNode;
};

export function Tabs({ value, defaultValue, onValueChange, children }: Props) {
  const [internal, setInternal] = useState(defaultValue ?? '');
  const isControlled = value !== undefined;
  const activeId = isControlled ? value : internal;
  const setActiveId = (next: string) => {
    if (!isControlled) setInternal(next);
    onValueChange?.(next);
  };
  // ...rest
}
```

### asChild slot pattern (Radix)
```typescript
import { Slot } from '@radix-ui/react-slot';

function Trigger({ asChild, children, value }: {
  asChild?: boolean;
  value: string;
  children: React.ReactNode;
}) {
  const { setActiveId } = useTabs();
  const Comp = asChild ? Slot : 'button';
  return (
    <Comp onClick={() => setActiveId(value)} role="tab">
      {children}
    </Comp>
  );
}

// Usage: customize button as anchor
<Tabs.Trigger value="x" asChild>
  <a href="#x">Section X</a>
</Tabs.Trigger>
```

### Form field compound
```typescript
const FieldContext = createContext<{ id: string; errorId: string } | null>(null);

export function Field({ children }: { children: React.ReactNode }) {
  const id = useId();
  return (
    <FieldContext.Provider value={{ id, errorId: `${id}-error` }}>
      <div className="field">{children}</div>
    </FieldContext.Provider>
  );
}

function Label({ children }: { children: React.ReactNode }) {
  const { id } = useContext(FieldContext)!;
  return <label htmlFor={id}>{children}</label>;
}

function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
  const { id, errorId } = useContext(FieldContext)!;
  return <input id={id} aria-describedby={errorId} {...props} />;
}

function Error({ children }: { children: React.ReactNode }) {
  const { errorId } = useContext(FieldContext)!;
  return <span id={errorId} role="alert">{children}</span>;
}

Field.Label = Label;
Field.Input = Input;
Field.Error = Error;
```

### Server Components note
```typescript
// Compound components 의 Context — Client Component only.
// 매 Server Component layout 에서:
//   <Tabs>  ← Client Component (provides context)
//     <Tabs.Panel value="x">  ← Client (consumes context)
//       <ServerOnlyData />     ← can be Server Component child
//     </Tabs.Panel>
//   </Tabs>
'use client';
```

## 매 결정 기준
| 상황 | Approach |
|---|---|
| Standard widgets (tabs, menu) | Context-based compound |
| Wrap user element | asChild + Slot |
| Caller controls state | Controlled variant |
| Behavior-only library | Headless compound (Radix style) |
| Server Components mixed | 'use client' on the parent + child triggers |

**기본값**: Context-based, namespace exports (`Tabs.Trigger`), controlled+uncontrolled support, `useId` for a11y, `useContext` 로 misuse 방지.

## 🔗 Graph
- 부모: [[Component-Based Architecture (CBA)]] · [[React Patterns]]
- 변형: [[Headless UI]] · [[Render_Props|Render Props]]
- 응용: [[Component Library Architecture]] · [[Radix UI]] · [[shadcn-ui]]
- Adjacent: [[A11y]] · [[Modern_Web_Rendering_and_Optimization|Server Components]]

## 🤖 LLM 활용
**언제**: building reusable widgets (tabs/menu/dialog), design system primitives, replacing prop-explosion components, headless library.
**언제 X**: 1-shot static UI, simple presentational components, server-only components (Context 의 X).

## ❌ 안티패턴
- **`React.Children` cloning**: brittle with refs, breaks with fragments — Context 가 표준.
- **Missing context throw**: child used standalone gives undefined error — explicit throw with helpful message.
- **Over-namespacing**: 5+ children types — split into multiple components.
- **Forgetting a11y**: tabs without role/aria-* — Radix-style attribute mapping 필수.

## 🧪 검증 / 중복
- Verified (Kent C. Dodds compound components / Radix UI source / React 19 docs / WAI-ARIA 1.2).
- 신뢰도 A.

## 🕓 Changelog
| 날짜 | 변경 |
|---|---|
| 2026-05-08 | Phase 1 |
| 2026-05-10 | Manual cleanup — Context-based Tabs + asChild + form field 패턴 |
