Compound Components sind eines der elegantesten und meistgenutzten Patterns moderner React-UI-Libraries — Radix UI, Headless UI, Reach UI, React Aria und shadcn/ui bauen ihre Komponenten alle nach diesem Schema. Die Idee: statt einer monolithischen Komponente mit zwanzig Konfigurations-Props (<Tabs items={…} renderTab={…} renderPanel={…} onChange={…} />) liefert die Library mehrere kleine Komponenten, die zusammen eine semantische Einheit bilden: <Tabs>, <TabList>, <Tab>, <TabPanels>, <TabPanel>. Der Konsument komponiert sie im JSX wie HTML-Elemente — und intern teilen sie sich Zustand über React Context. Das Vorbild aus dem HTML-Universum: <select> und <option> funktionieren genauso. Dieses Pattern macht UI-Komponenten gleichzeitig flexibler und klarer in der Verwendung.
Das Problem — monolithische Komponenten
Bevor wir das Pattern bauen, betrachten wir das Problem, das es löst. Eine naive Tabs-Komponente sieht oft so aus:
// Eine Komponente, alle Konfiguration über Props
<Tabs
items={[
{ id: '1', label: 'Account', content: <AccountPanel /> },
{ id: '2', label: 'Settings', content: <SettingsPanel /> },
{ id: '3', label: 'Billing', content: <BillingPanel />, disabled: true },
]}
defaultActive="1"
onChange={(id) => console.log(id)}
renderTab={(item, isActive) => (
<span style={{ fontWeight: isActive ? 'bold' : 'normal' }}>
{item.label}
</span>
)}
/>Probleme:
- Die Struktur ist im Konsumenten unsichtbar. Wo ist die Liste, wo das Panel? Die Komponente kapselt das — Anpassungen am HTML/CSS-Aufbau brauchen neue Props.
- Anordnung ist starr. Wenn der Konsument die Tab-Liste unter den Panels haben will, braucht es eine neue Prop (
tabsPosition="bottom") oder einen kompletten Fork der Komponente. - Konfigurations-Explosion. Jedes Detail (Icon links/rechts, Trenner zwischen Tabs, Animation) wird zu einer neuen Prop. Die API wächst und wächst.
- Custom-Rendering nur über Render-Props/HOC. Wer ein Tab anders rendern will, muss eine
renderTab-Funktion übergeben.
Compound Components drehen die Verantwortung um: die Library liefert Bausteine, der Konsument komponiert. So sieht das gleiche Tabs-Beispiel nachher aus:
<Tabs defaultValue="1" onChange={(id) => console.log(id)}>
<Tabs.List>
<Tabs.Tab value="1">Account</Tabs.Tab>
<Tabs.Tab value="2">Settings</Tabs.Tab>
<Tabs.Tab value="3" disabled>Billing</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="1"><AccountPanel /></Tabs.Panel>
<Tabs.Panel value="2"><SettingsPanel /></Tabs.Panel>
<Tabs.Panel value="3"><BillingPanel /></Tabs.Panel>
</Tabs>Die Struktur ist sichtbar im Konsumenten. Reihenfolge, Anordnung, zusätzliches JSX (Trenner, Icons, eigene Wrapper) — alles möglich, ohne dass die Library neue Props braucht.
Implementierung — <Tabs> mit Context
Der Schlüssel zum Pattern ist React Context. Die Eltern-Komponente (<Tabs>) hält den State, die Sub-Komponenten lesen ihn über useContext. So bleibt der State implizit, ohne dass der Konsument ihn herumreichen muss.
import { createContext, useContext, useState, useId } from 'react';
// 1. Context für den geteilten State
const TabsContext = createContext(null);
// Helfer: wirft sprechende Fehler, wenn ein Sub-Component außerhalb von <Tabs> verwendet wird
function useTabsContext(component) {
const ctx = useContext(TabsContext);
if (!ctx) {
throw new Error(`<${component}> muss innerhalb von <Tabs> verwendet werden.`);
}
return ctx;
}
// 2. Root: hält den State und stellt ihn über Context bereit
function Tabs({ defaultValue, onChange, children }) {
const [active, setActive] = useState(defaultValue);
const baseId = useId();
const select = (value) => {
setActive(value);
onChange?.(value);
};
return (
<TabsContext.Provider value={{ active, select, baseId }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
// 3. List: semantischer Container für die Tab-Knöpfe
function TabsList({ children }) {
return (
<div className="tabs-list" role="tablist">
{children}
</div>
);
}
// 4. Tab: einzelner Knopf
function Tab({ value, disabled, children }) {
const { active, select, baseId } = useTabsContext('Tabs.Tab');
const isActive = active === value;
return (
<button
type="button"
role="tab"
id={`${baseId}-tab-${value}`}
aria-controls={`${baseId}-panel-${value}`}
aria-selected={isActive}
disabled={disabled}
onClick={() => select(value)}
data-state={isActive ? 'active' : 'inactive'}
>
{children}
</button>
);
}
// 5. Panel: zeigt seinen Inhalt, wenn sein value aktiv ist
function TabsPanel({ value, children }) {
const { active, baseId } = useTabsContext('Tabs.Panel');
if (active !== value) return null;
return (
<div
role="tabpanel"
id={`${baseId}-panel-${value}`}
aria-labelledby={`${baseId}-tab-${value}`}
>
{children}
</div>
);
}
// 6. Sub-Komponenten als Properties anhängen → Dot-Notation im Konsum
Tabs.List = TabsList;
Tabs.Tab = Tab;
Tabs.Panel = TabsPanel;
export default Tabs;Was an dieser Implementierung wichtig ist:
- Context als Brücke. Der State (
active) und der Setter (select) leben in<Tabs>. Alle Sub-Komponenten lesen sie viauseContext— der Konsument muss keinen State herumreichen. - Defensive Fehlermeldung. Wenn jemand
<Tabs.Tab>außerhalb von<Tabs>benutzt, wirftuseTabsContextmit klarem Fehlertext. Spart Stunden bei der Fehlersuche. useIdfür Accessibility.id/aria-controls/aria-labelledbymüssen über Tab und Panel zusammenpassen.useId(React 18+) liefert SSR-sichere stabile IDs.- Dot-Notation.
Tabs.List,Tabs.Tab,Tabs.Panelals Properties an der Root-Komponente machen den Zusammenhang im Konsum sofort sichtbar — der Import bleibt schmal (import Tabs from './Tabs'). - ARIA-Rollen.
role="tablist",role="tab",role="tabpanel"undaria-selectedsind keine Stilfrage, sondern Pflicht für barrierefreie Tabs.
Zweites Beispiel — <Select> mit <Option>
Ein zweites kanonisches Compound-Beispiel: ein eigenes Select-Element, das wie das HTML-Original strukturiert ist, aber stilbar und um Features wie Suche oder Icons erweiterbar bleibt.
import { createContext, useContext, useState } from 'react';
const SelectContext = createContext(null);
function Select({ value, onChange, placeholder = 'Bitte wählen', children }) {
const [isOpen, setOpen] = useState(false);
const [internalValue, setInternal] = useState(value);
const current = value !== undefined ? value : internalValue;
const choose = (v) => {
setInternal(v);
onChange?.(v);
setOpen(false);
};
// Label des aktiven Options-Elements aus den Children herauslesen
const activeLabel = (() => {
let label = placeholder;
// Children werden hier nur zur Anzeige der gewählten Option durchsucht
// (in einer Production-Implementierung würde man React.Children.toArray nutzen)
return label;
})();
return (
<SelectContext.Provider value={{ current, choose }}>
<div className="select" data-open={isOpen}>
<button
type="button"
className="select-trigger"
onClick={() => setOpen(o => !o)}
aria-haspopup="listbox"
aria-expanded={isOpen}
>
{current ?? placeholder}
</button>
{isOpen && (
<ul className="select-list" role="listbox">
{children}
</ul>
)}
</div>
</SelectContext.Provider>
);
}
function Option({ value, children }) {
const ctx = useContext(SelectContext);
if (!ctx) throw new Error('<Select.Option> muss innerhalb von <Select> verwendet werden.');
const isSelected = ctx.current === value;
return (
<li
role="option"
aria-selected={isSelected}
onClick={() => ctx.choose(value)}
data-state={isSelected ? 'selected' : 'unselected'}
>
{children}
</li>
);
}
Select.Option = Option;
export default Select;Konsum:
function App() {
const [country, setCountry] = useState('DE');
return (
<Select value={country} onChange={setCountry}>
<Select.Option value="DE">Deutschland</Select.Option>
<Select.Option value="AT">Österreich</Select.Option>
<Select.Option value="CH">Schweiz</Select.Option>
</Select>
);
}Der Konsument schreibt eine HTML-ähnliche Struktur. Er könnte zwischen Options-Items einen Trenner einbauen (<hr />), eine Gruppen-Überschrift (<Select.Group> wenn vorhanden) oder Icons pro Option — ohne neue Props. Genau das ist der Punkt des Patterns.
Controlled und Uncontrolled
Compound Components erben die Controlled-vs-Uncontrolled-Frage von Form-Inputs. Drei Varianten sind üblich:
// 1. Uncontrolled mit Default — Komponente verwaltet ihren State selbst
<Tabs defaultValue="1">
…
</Tabs>
// 2. Controlled — Konsument hält den State
const [tab, setTab] = useState('1');
<Tabs value={tab} onChange={setTab}>
…
</Tabs>
// 3. Hybrid (wie React's <input>) — beide unterstützen, value gewinnt wenn gesetzt
function Tabs({ value, defaultValue, onChange, children }) {
const [internal, setInternal] = useState(defaultValue);
const isControlled = value !== undefined;
const current = isControlled ? value : internal;
const select = (v) => {
if (!isControlled) setInternal(v);
onChange?.(v);
};
// …
}Die Hybrid-Variante ist der React-Standard — <input> selbst funktioniert so. Bei seriösen UI-Libraries (Radix, HeadlessUI) findet man immer beide Modi.
Compound vs. „Slot-Props"
Es gibt zwei Implementierungs-Stile für Compound Components:
Stil A — Implizite Komposition (Context)
So wie oben gezeigt: <Tabs> rendert seine Kinder, die Kinder lesen den Tabs-State über Context. Vorteil: maximal flexibel im Konsumenten — beliebige Verschachtelung, Mischen mit anderem JSX, Anordnung frei.
Stil B — Explizite Slot-Props
Manche Libraries (z.B. älteres Material UI) übergeben dedizierte Sub-Komponenten als Props, nicht als Children:
<Card
header={<CardHeader>Titel</CardHeader>}
footer={<CardFooter>Mehr…</CardFooter>}
>
Inhalt
</Card>Slot-Props sind einfacher zu typisieren, aber starrer: der Konsument kann den Header nicht weglassen oder mehrere Footer haben. Für UIs mit fester Struktur (Card, Modal) funktioniert das gut; für variable Strukturen (Tabs, Select, Menu) ist die Context-Variante besser.
Stolperfallen
React.Children.toArray vs. Context
Frühe Compound-Komponenten (vor Context-Verbreitung) haben die Kinder durchsucht und über React.Children.map/React.Children.toArray und cloneElement Props in sie injiziert. Dieses Pattern ist heute weitgehend obsolet — es bricht, sobald die Kinder nicht direkt unter <Tabs> liegen (z.B. in einem Wrapper-Div). Context funktioniert über beliebige Verschachtelung hinweg.
Re-Renders durch Context-Splitting
Wenn der Tabs-Kontext sowohl den active-State als auch den select-Callback enthält, re-rendert jeder Kontext-Konsument bei Tab-Wechsel. Bei aufwendigen Panels lohnt es sich, zwei Contexts zu nutzen — einen für den schnell wechselnden State, einen für den stabilen Setter. Siehe dazu auch Performance-Fallstricke bei Context.
Strict-Typing in TypeScript
Wenn man Tabs.Tab und Tabs.Panel typisiert, sollten die value-Typen idealerweise gekoppelt sein — ein Panel mit einer value, die in keinem Tab vorkommt, sollte ein Type-Fehler sein. Das geht mit TypeScript-Generics, ist aber nicht trivial. Die meisten Libraries akzeptieren hier einen pragmatischen Trade-off und typisieren value einfach als string.
Erlaubte Kinder einschränken
Wenn man sicher sein will, dass nur erlaubte Sub-Komponenten in <Tabs> landen, kann man eine Runtime-Validierung schreiben (per Tab.componentTypeBrand oder Ähnlichem). In der Praxis macht man das selten — die Konvention reicht, und Radix etc. erzwingen es nicht.
Vorbild: HTML-`
Compound Components emulieren genau das Verhalten der HTML-Elemente, die strukturell zusammengehören. Wer die Lesart <select>…<option>…</option>…</select> natürlich findet, versteht das Pattern intuitiv.
Context ist der moderne Implementierungs-Weg
Frühe Compound-Komponenten haben mit React.Children.map und cloneElement gearbeitet. Heute ist Context der Standard — funktioniert über beliebige Verschachtelung hinweg, kein Fragiles über die direkte Kinder-Hierarchie hinaus.
Dot-Notation macht den Zusammenhang sichtbar
Tabs.List, Tabs.Tab, Tabs.Panel als Properties an der Root-Komponente sind die Konvention. Im Konsum sieht man sofort, dass diese Komponenten zusammengehören — und der Import bleibt eine Zeile.
Sprechende Fehler-Meldungen bei Fehlplatzierung
Ein Helper wie useTabsContext('Tabs.Panel') sollte mit klarem Text werfen, wenn das Sub-Component außerhalb seiner Root verwendet wird. Spart Stunden bei der Fehlersuche, vor allem in fremdem Code.
`useId` für ARIA-Verknüpfungen
Tab und Panel müssen sich via id/aria-controls/aria-labelledby aufeinander beziehen. useId (React 18+) liefert stabile, SSR-sichere IDs — nutze einen Basis-useId in der Root und leite tab-/panel-IDs daraus ab.
Controlled, Uncontrolled, oder hybrid
Wie bei <input> gilt: gute Compound-Komponenten unterstützen beide Modi. value und defaultValue als Props, intern wird zwischen Controlled (wenn value !== undefined) und Uncontrolled umgeschaltet.
Slot-Props sind die starrere Alternative
<Card header={…} footer={…}> ist einfacher zu typisieren, aber starrer in der Struktur. Für UIs mit fester Anatomie funktioniert es gut; für variable Listen (Tabs, Menus) ist die Context-Compound-Variante besser.
Real-World: Radix, HeadlessUI, shadcn/ui, React Aria
Wer Compound Components in „industrieller" Qualität sehen will, schaut die Source-Code-Repositories dieser Libraries an. Die Patterns dort (insbesondere bei Radix UI) sind die de-facto-Standards für moderne React-UI-APIs.
Verwandte Artikel
- Children Prop — die Grundlage, auf der Compound Components aufbauen
- Komposition statt Inheritance — das übergeordnete React-Prinzip
- Context API Verwendung — wie Context praktisch benutzt wird
- Performance-Fallstricke bei Context — wichtig bei Compound mit häufig wechselndem State
- Provider Pattern — die Provider-Schicht für app-weite Kompositionen
Externe Quellen
- Radix UI: Tabs — die kanonische moderne Compound-Tabs-Implementierung mit voller Accessibility.
- Headless UI: Tabs — Tailwind-affine Compound-Tabs-Variante.
- Kent C. Dodds: Compound Components — der Artikel, der das Pattern in der React-Community popularisiert hat.
- WAI-ARIA: Tabs Pattern — die Accessibility-Spezifikation für Tabs.
- React Aria: useTabList — Adobe's Hook-basierte Variante.