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:

TypeScript Naiv-Tabs.jsx
// 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:

TypeScript Compound-Tabs.jsx
<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.

TypeScript Tabs.jsx
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:

  1. Context als Brücke. Der State (active) und der Setter (select) leben in <Tabs>. Alle Sub-Komponenten lesen sie via useContext — der Konsument muss keinen State herumreichen.
  2. Defensive Fehlermeldung. Wenn jemand <Tabs.Tab> außerhalb von <Tabs> benutzt, wirft useTabsContext mit klarem Fehlertext. Spart Stunden bei der Fehlersuche.
  3. useId für Accessibility. id/aria-controls/aria-labelledby müssen über Tab und Panel zusammenpassen. useId (React 18+) liefert SSR-sichere stabile IDs.
  4. Dot-Notation. Tabs.List, Tabs.Tab, Tabs.Panel als Properties an der Root-Komponente machen den Zusammenhang im Konsum sofort sichtbar — der Import bleibt schmal (import Tabs from './Tabs').
  5. ARIA-Rollen. role="tablist", role="tab", role="tabpanel" und aria-selected sind 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.

TypeScript Select.jsx
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:

TypeScript App.jsx
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:

TypeScript Varianten.jsx
// 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:

TypeScript SlotStil.jsx
<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-`