f8b21af4be
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>
262 lines
7.9 KiB
Markdown
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 패턴 |
|