Compound Components
Prop-Drilling und monolithische Komponenten mit dutzenden Props sind ein sicheres Zeichen für eine Architektur, die nicht skaliert. Das Compound Components Pattern in React löst dieses Problem durch explizite Komposition: Sub-Komponenten teilen implizit State über Context, die API bleibt selbstdokumentierend und maximal flexibel.
Inhaltsverzeichnis
- 1. Das Problem mit monolithischen Props-Komponenten
- 2. Das Compound Components Konzept erklärt
- 3. Implementierung mit React Context
- 4. Praxisbeispiel: Eigenes Select-Dropdown
- 5. Praxisbeispiel: Tabs-Komponente
- 6. Typisierung und TypeScript-Best-Practices
- 7. Zugänglichkeit (ARIA) in Compound Components
- 8. Testing-Strategie für Compound Components
- 9. Compound Components vs. andere Patterns
- 10. Zusammenfassung
- 11. FAQ
1. Das Problem mit monolithischen Props-Komponenten
Der Ausgangspunkt für das Compound Components Pattern ist ein Problem, das in fast jedem React-Projekt nach einigen Wochen auftaucht: Eine Komponente wächst mit immer mehr Props. Eine Select-Komponente beginnt mit options und value. Dann kommt disabled, placeholder, isSearchable, renderOption, renderTrigger, onOpen, onClose, maxHeight dazu. Bald hat die Komponente zwanzig Props, von denen in jedem Einsatz nur fünf genutzt werden. Die Dokumentation wächst, der Code wird schwer lesbar, das Testing aufwendig.
Das eigentliche Problem ist, dass eine monolithische Komponente versucht, alle möglichen Variationen durch Props zu steuern. Das verletzt das Open/Closed Principle: Die Komponente muss bei jeder neuen Anforderung geöffnet und verändert werden. Das Compound Components Pattern löst dieses Problem durch Umkehrung: Statt einer Komponente mit vielen Props gibt es mehrere spezialisierte Sub-Komponenten, die durch Komposition kombiniert werden. Jede Sub-Komponente macht genau eine Sache, und der Verbraucher entscheidet, wie sie zusammengesetzt werden.
Ein klassisches Beispiel aus der React-Welt ist das <select>-Element im Browser: Es kennt keine options-Prop als Array, sondern Sub-Elemente: <option> und <optgroup>. Das ist Compound Components als HTML-Konzept. Dasselbe Muster lässt sich in React für eigene Komponenten-Bibliotheken umsetzen – und populäre Libraries wie Headless UI, Radix UI und Reach UI basieren genau darauf.
2. Das Compound Components Konzept erklärt
Das Compound Components Pattern besteht aus zwei Teilen: einer Elternkomponente, die den geteilten State hält und über React Context nach unten weitergibt, und Sub-Komponenten, die diesen Context konsumieren, ohne dass Props explizit nach unten gereicht werden müssen. Die Elternkomponente und ihre Sub-Komponenten bilden zusammen eine logische Einheit – eine Compound Component.
Der Verbraucher der Komponente schreibt explizite, selbstdokumentierende JSX. Statt <Tabs activeTab="details" tabs={[...]} onTabChange={...}/> schreibt er <Tabs><Tabs.List><Tabs.Tab id="details">Details</Tabs.Tab></Tabs.List><Tabs.Panel id="details">...</Tabs.Panel></Tabs>. Das ist länger, aber selbstdokumentierend, flexibel und erweiterbar ohne Änderungen an der Komponente. Sub-Komponenten können an verschiedenen Stellen im JSX-Baum platziert werden – die interne Context-Kommunikation funktioniert unabhängig von der DOM-Struktur.
// accordion.tsx — Minimal Compound Components implementation
import { createContext, useContext, useState, type ReactNode } from 'react';
// Internal context — not exported, consumers can't access it directly
interface AccordionContextValue {
openItem: string | null;
toggle: (id: string) => void;
}
const AccordionContext = createContext<AccordionContextValue | null>(null);
function useAccordionContext() {
const ctx = useContext(AccordionContext);
if (!ctx) throw new Error('<Accordion.Item> must be inside <Accordion>');
return ctx;
}
// Root component — holds shared state and provides context
function Accordion({ children, defaultOpen }: { children: ReactNode; defaultOpen?: string }) {
const [openItem, setOpenItem] = useState<string | null>(defaultOpen ?? null);
const toggle = (id: string) => setOpenItem(prev => (prev === id ? null : id));
return (
<AccordionContext.Provider value={ { openItem, toggle } }>
<div className="divide-y divide-slate-200 rounded-2xl border border-slate-200 overflow-hidden">
{children}
</div>
</AccordionContext.Provider>
);
}
// Sub-components — consume context without prop drilling
Accordion.Item = function AccordionItem({ id, title, children }: { id: string; title: string; children: ReactNode }) {
const { openItem, toggle } = useAccordionContext();
const isOpen = openItem === id;
return (
<div>
<button
onClick={() => toggle(id)}
aria-expanded={isOpen}
aria-controls={`panel-${id}`}
className="w-full flex justify-between items-center px-6 py-4 font-semibold text-slate-800 hover:bg-slate-50"
>
{title}
<span className={`transition-transform ${isOpen ? 'rotate-180' : ''}`}>▾</span>
</button>
{isOpen && (
<div id={`panel-${id}`} role="region" className="px-6 py-4 bg-slate-50 text-slate-700 text-sm">
{children}
</div>
)}
</div>
);
};
// Usage — self-documenting, no prop arrays, fully composable
export function FaqSection() {
return (
<Accordion defaultOpen="shipping">
<Accordion.Item id="shipping" title="Wie lange dauert die Lieferung?">
Standardlieferung 3–5 Werktage, Express 1–2 Werktage.
</Accordion.Item>
<Accordion.Item id="returns" title="Wie funktioniert die Rückgabe?">
Kostenfrei innerhalb von 30 Tagen.
</Accordion.Item>
</Accordion>
);
}
3. Implementierung mit React Context
Der Kern jedes Compound Components Pattern ist ein interner React Context, der zwischen der Elternkomponente und den Sub-Komponenten kommuniziert. Dieser Context wird bewusst nicht exportiert – er ist ein Implementierungsdetail der Compound Component, kein öffentliches API. Konsumenten der Compound Component sehen nur die Komponenten-Klasse und ihre Sub-Komponenten, nicht den internen Context.
Die Custom Hook useXxxContext() kapselt den useContext-Aufruf und fügt eine Invarianz-Prüfung hinzu: Wenn eine Sub-Komponente außerhalb ihrer Elternkomponente verwendet wird, wirft der Hook einen klaren Fehler mit einer hilfreichen Fehlermeldung. Das verbessert die Entwicklererfahrung erheblich gegenüber einem kryptischen Cannot read properties of null-Fehler. Die Elternkomponente stellt den Context über einen Provider bereit und hält den geteilten State mit useState oder useReducer.
4. Praxisbeispiel: Eigenes Select-Dropdown
Ein Select-Dropdown ist ein perfektes Beispiel für Compound Components, weil es einen komplexen internen State hat (offen/geschlossen, fokussierte Option, ausgewählter Wert) und gleichzeitig maximale Flexibilität bei der Darstellung der Optionen bieten muss. Mit einem Props-Array options={[...]} kann man keine komplexen Option-Layouts mit Icons, Beschreibungen oder Sub-Gruppen umsetzen. Mit Compound Components ist das trivial.
Das Muster erlaubt es, die Optionen beliebig zu strukturieren und zu erweitern, ohne die Select-Komponente zu verändern. Neue Optionstypen (Trennlinien, Überschriften, Gruppen) entstehen einfach durch neue Sub-Komponenten. Der Konsument der Select-Komponente hat vollständige Kontrolle über das Layout jeder Option, während die Kernlogik (Tastaturnavigation, ARIA, Öffnen und Schließen) in der Elternkomponente bleibt.
// select.tsx — Compound Components Select with keyboard navigation
import { createContext, useContext, useState, useRef, useCallback, type ReactNode } from 'react';
interface SelectContextValue {
isOpen: boolean;
selectedValue: string | null;
open: () => void;
close: () => void;
select: (value: string, label: string) => void;
selectedLabel: string | null;
}
const SelectContext = createContext<SelectContextValue | null>(null);
const useSelectContext = () => {
const ctx = useContext(SelectContext);
if (!ctx) throw new Error('Select sub-components must be inside <Select>');
return ctx;
};
interface SelectProps {
value?: string | null;
onChange?: (value: string) => void;
children: ReactNode;
}
function Select({ value: controlledValue, onChange, children }: SelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [internalValue, setInternalValue] = useState<string | null>(null);
const [selectedLabel, setSelectedLabel] = useState<string | null>(null);
const selectedValue = controlledValue !== undefined ? controlledValue : internalValue;
const select = useCallback((value: string, label: string) => {
setInternalValue(value);
setSelectedLabel(label);
setIsOpen(false);
onChange?.(value);
}, [onChange]);
return (
<SelectContext.Provider value={ {
isOpen, selectedValue, selectedLabel,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
select,
} }>
<div className="relative">{children}</div>
</SelectContext.Provider>
);
}
// Trigger sub-component — renders the visible button
Select.Trigger = function SelectTrigger({ placeholder = 'Bitte wählen ...' }: { placeholder?: string }) {
const { isOpen, open, close, selectedLabel } = useSelectContext();
return (
<button
type="button"
onClick={() => isOpen ? close() : open()}
aria-haspopup="listbox"
aria-expanded={isOpen}
className="w-full flex justify-between items-center px-4 py-2 border border-slate-300 rounded-lg bg-white text-sm font-medium text-slate-700 hover:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
>
<span className={selectedLabel ? 'text-slate-800' : 'text-slate-400'}>
{selectedLabel ?? placeholder}
</span>
<span className={`transition-transform text-slate-400 ${isOpen ? 'rotate-180' : ''}`}>▾</span>
</button>
);
};
// Options container
Select.Options = function SelectOptions({ children }: { children: ReactNode }) {
const { isOpen } = useSelectContext();
if (!isOpen) return null;
return (
<ul role="listbox" className="absolute z-50 mt-1 w-full bg-white border border-slate-200 rounded-xl shadow-lg py-1 max-h-60 overflow-auto">
{children}
</ul>
);
};
// Individual option
Select.Option = function SelectOption({ value, children }: { value: string; children: ReactNode }) {
const { selectedValue, select } = useSelectContext();
const isSelected = selectedValue === value;
return (
<li
role="option"
aria-selected={isSelected}
onClick={() => select(value, typeof children === 'string' ? children : value)}
className={`px-4 py-2 text-sm cursor-pointer flex items-center gap-2 ${isSelected ? 'bg-sky-50 text-sky-700 font-semibold' : 'text-slate-700 hover:bg-slate-50'}`}
>
{isSelected && <span>✓</span>}
{children}
</li>
);
};
5. Praxisbeispiel: Tabs-Komponente
Eine Tabs-Komponente ist eines der häufigsten UI-Muster in Web-Anwendungen und illustriert besonders gut, warum das Compound Components Pattern gegenüber Props-basierten Ansätzen überlegen ist. Eine Props-basierte Tabs-Komponente erwartet typischerweise ein Array mit Tab-Konfigurationsobjekten: tabs={[{id, label, content}]}. Das funktioniert für einfache Fälle, aber sobald Tabs unterschiedliche Layouts brauchen – Icons, Badges, deaktivierte Zustände, benutzerdefinierte Inhalte – explodiert die Konfiguration.
Mit Compound Components ist die Lösung sauber: Tabs.List enthält Tabs.Tab-Elemente, die beliebigen Inhalt rendern können. Tabs.Panel enthält den Inhalt des jeweiligen Tabs. Die Verbindung zwischen Tab und Panel erfolgt über eine gemeinsame id. Der Entwickler kann Tabs mit Icons, Badges, langen Beschriftungen oder benutzerdefinierten Layouts erstellen, ohne die Tabs-Komponente zu verändern. Das ist Open/Closed Principle in der Praxis.
6. Typisierung und TypeScript-Best-Practices
TypeScript und Compound Components harmonieren sehr gut, erfordern aber etwas mehr Überlegung als einfache Props-Typen. Der Context-Typ wird als Interface definiert und beim createContext-Aufruf als Generischer Parameter übergeben. Das null-Initial-Value erzwingt den Null-Check im Custom Hook, der dann einen präzisen Fehler wirft. Dieser Null-Check ist keine Schönheitsoperation, sondern ein Sicherheitsnetz, das Entwickler vor unverständlichen Runtime-Fehlern schützt.
Sub-Komponenten werden typischerweise als direkte Properties der Elternkomponente definiert: Accordion.Item = function() {...}. Das funktioniert in TypeScript mit einer kleinen Ergänzung: Der Typ der Elternkomponente muss explizit um die Sub-Komponenten erweitert werden. Ein eleganter Weg ist die Definition der Sub-Komponenten zuerst und danach die Zuweisung: const Select = Object.assign(SelectRoot, { Trigger: SelectTrigger, Options: SelectOptions, Option: SelectOption }). Das gibt TypeScript alle nötigen Informationen für korrekte Auto-Vervollständigung.
// dialog.tsx — Typed Compound Component with Object.assign pattern
import { createContext, useContext, useId, useState, type ReactNode } from 'react';
// Context type — exported for external extension if needed
export interface DialogContextValue {
isOpen: boolean;
dialogId: string;
titleId: string;
descriptionId: string;
open: () => void;
close: () => void;
}
const DialogContext = createContext<DialogContextValue | null>(null);
export function useDialogContext(): DialogContextValue {
const ctx = useContext(DialogContext);
if (!ctx) throw new Error('Dialog sub-components must be used inside <Dialog>');
return ctx;
}
// Root component using function keyword for clear stack traces
function DialogRoot({ children, defaultOpen = false }: { children: ReactNode; defaultOpen?: boolean }) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const id = useId(); // React 18 — generates stable, unique ID
return (
<DialogContext.Provider value={ {
isOpen,
dialogId: `dialog-${id}`,
titleId: `dialog-title-${id}`,
descriptionId: `dialog-desc-${id}`,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
} }>
{children}
</DialogContext.Provider>
);
}
// Sub-components with explicit typing
const DialogTrigger = ({ children }: { children: ReactNode }) => {
const { open } = useDialogContext();
return <button onClick={open}>{children}</button>;
};
const DialogPanel = ({ children }: { children: ReactNode }) => {
const { isOpen, dialogId, titleId, descriptionId, close } = useDialogContext();
if (!isOpen) return null;
return (
<div role="dialog" id={dialogId} aria-labelledby={titleId} aria-describedby={descriptionId} aria-modal="true">
<div className="fixed inset-0 bg-black/50 z-40" onClick={close} />
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-lg w-full p-6">{children}</div>
</div>
</div>
);
};
const DialogTitle = ({ children }: { children: ReactNode }) => {
const { titleId } = useDialogContext();
return <h2 id={titleId} className="text-xl font-bold text-slate-800 mb-2">{children}</h2>;
};
const DialogDescription = ({ children }: { children: ReactNode }) => {
const { descriptionId } = useDialogContext();
return <p id={descriptionId} className="text-slate-600 text-sm">{children}</p>;
};
// Object.assign pattern — TypeScript sees all sub-components
export const Dialog = Object.assign(DialogRoot, {
Trigger: DialogTrigger,
Panel: DialogPanel,
Title: DialogTitle,
Description: DialogDescription,
});
7. Zugänglichkeit (ARIA) in Compound Components
Compound Components sind ideal für zugängliche UI-Muster, weil die ARIA-Attribute und die DOM-Struktur eng mit dem State der Elternkomponente verknüpft sind. Der Context hält nicht nur den visuellen State, sondern auch die IDs, die für ARIA-Beziehungen zwischen Elementen nötig sind. useId aus React 18 generiert stabile, eindeutige IDs, die auch bei Server-Side-Rendering korrekt funktionieren.
Bei einem Tabs-Pattern hält die Elternkomponente die ID des aktiven Tabs, und die Tabs.Tab-Sub-Komponente setzt aria-selected basierend auf einem Vergleich mit dem aktiven Tab aus dem Context. Die Tabs.Panel-Komponente setzt aria-labelledby mit der ID des zugehörigen Tabs. Diese Beziehungen entstehen automatisch durch den geteilten Context, ohne dass der Verbraucher der Komponente auch nur einen einzigen ARIA-Attribut setzen muss.
8. Testing-Strategie für Compound Components
Compound Components lassen sich mit React Testing Library sehr gut testen, weil sie semantisches HTML rendern, das direkt über ARIA-Rollen und -Labels ansprechbar ist. Ein Accordion-Test öffnet ein Element über einen Klick auf den Button, der mit einem zugänglichen Namen ansprechbar ist, und prüft dann, ob der Panel-Inhalt sichtbar ist. Das entspricht genau dem Verhalten, das ein Tastaturbenutzer oder Screenreader-Nutzer erlebt.
Die Elternkomponente kann auch isoliert getestet werden, indem nur bestimmte Sub-Komponenten in einem Test-Wrapper zusammengestellt werden. Das erlaubt gezielte Tests für einzelne State-Übergänge ohne den Overhead der vollständigen Komponente. Snapshot-Tests sind bei Compound Components weniger wertvoll als Interaktionstests, weil die visuelle Ausgabe stark von der Komposition abhängt, die der Verbraucher wählt.
9. Compound Components vs. andere Patterns
Das Compound Components Pattern ist eines von mehreren Kompositionsmustern in React. Es lohnt sich, es gegen die Alternativen abzuwägen, um für jede Situation das richtige Werkzeug zu wählen.
| Pattern | Stärke | Schwäche | Typischer Einsatz |
|---|---|---|---|
| Compound Components | Maximale Flexibilität, selbstdokumentierend | Mehr Code als Props-API | Tabs, Accordion, Select, Dialog |
| Props-API | Wenig Code, einfach zu dokumentieren | Props-Explosion bei vielen Variationen | Einfache Buttons, Icons, Labels |
| Render Props | Maximale Render-Kontrolle | Callback-Hell, schwer lesbar | Heute meist durch Custom Hooks ersetzt |
| Custom Hook | Logic-Extraktion ohne UI-Kopplung | Kein impliziter State-Share zwischen Komponenten | Formulare, API-Calls, Event-Handler |
| HOC | Querschnitt-Concerns (Auth, Logging) | Wrapper-Hell, Props-Konflikte | Selten in modernem React |
In der Praxis kombiniert man diese Patterns. Eine Tabs-Komponente basiert auf Compound Components für die Struktur, nutzt intern Custom Hooks für die Tastaturnavigation und bietet nach außen eine Props-API für einfache Anwendungsfälle als Alternative zur vollen Komposition. Das gibt Konsumenten die Wahl: einfache API für einfache Fälle, vollständige Komposition für komplexe Layouts.