7.9 KiB
7.9 KiB
id, title, category, status, canonical_id, aliases, duplicate_of, source_trust_level, confidence_score, verification_status, tags, raw_sources, last_reinforced, github_commit, tech_stack
| id | title | category | status | canonical_id | aliases | duplicate_of | source_trust_level | confidence_score | verification_status | tags | raw_sources | last_reinforced | github_commit | tech_stack | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| wiki-2026-0508-compound-components | Compound Components | 10_Wiki/Topics | verified | self |
|
none | A | 0.95 | applied |
|
2026-05-10 | pending |
|
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.Childrencloning (legacy, fragile with refs/server components).- State reducer (advanced — caller customizes reducer).
asChildslot (Radix — wraps user element).
매 응용
- Tabs, Accordion, Disclosure.
- Menu, Dropdown, Combobox.
- Dialog, Drawer, Popover.
- Form fields with label/error/description.
- DataTable column children.
💻 패턴
Context-based Tabs
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
<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
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)
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
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
// 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 · Slots
- 응용: Component Library Architecture · Radix UI · shadcn-ui
- Adjacent: React Context · A11y · 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.Childrencloning: 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 패턴 |