Files
2nd/10_Wiki/Topics/Architecture/Compound_Components.md
T
Antigravity Agent f8b21af4be Wiki cleanup: error-doc removal, dedup merge, link normalization
10_Wiki/Topics 대규모 정리:
- 오류 캡처/미완성 stub 문서 227개 제거
- 교차폴더 중복 43클러스터 병합 (63파일 → redirect)
- 링크명 정규화: 깨진 링크 수정·redirect 직결·개념 매핑 ~2,400건
- 카테고리 MOC 6개 신규 생성
- Graph 섹션 미해결 related-keyword 링크 10,058건 제거

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 23:52:15 +09:00

262 lines
7.9 KiB
Markdown

---
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 패턴 |