Das asChild-Pattern und wann sich Polymorphic Components wirklich lohnen
)
Kurzer Rückblick + das Problem, das as nicht löst
In Teil 1 haben wir das as-Pattern von Grund auf aufgebaut: ein Button, der wahlweise als <button> oder <a> gerendert wird, typsicher inklusive eigener Props und ref-Forwarding. Das funktioniert hervorragend, solange das Ziel ein simples HTML-Tag ist.
Aber was, wenn du eigentlich schon eine fertige, komplexe Komponente hast? Stell dir eine Tooltip.Trigger-Komponente vor, die Hover-Events und aria-describedby an ein Element hängen soll. Mit as müsstest du schreiben:
<Tooltip.Trigger as={IconButton} icon={<TrashIcon />} onDelete={...}>Damit müsste Tooltip.Trigger jetzt alle Props von IconButton kennen und typsicher durchreichen. Bei tief verschachtelten oder generischen Komponenten wird das kaum noch sauber lösbar – genau hier setzt asChild an.
asChild: Verhalten übertragen statt Element austauschen
Die Idee: Statt "rendere mich als dieses Element" sagt man "nimm das Kind, das ich bekomme, und häng meine Props daran – ohne ein zusätzliches DOM-Element zu erzeugen."
<Tooltip.Trigger asChild>
<IconButton icon={<TrashIcon />} onDelete={...} />
</Tooltip.Trigger>Tooltip.Trigger muss jetzt nichts mehr über IconButton wissen. Die minimale Implementierung dahinter:
import { cloneElement, isValidElement } from 'react';
function TooltipTrigger({ asChild, children, ...triggerProps }) {
if (asChild && isValidElement(children)) {
return cloneElement(children, {
...triggerProps,
...children.props,
});
}
// Fallback: eigenes Wrapper-Element
return <span {...triggerProps}>{children}</span>;
}cloneElement(children, neueProps) erzeugt ein neues React-Element, das aussieht wie children, aber mit zusätzlichen Props. Ohne asChild würde TooltipTrigger ein eigenes <span> um das Kind legen; mit asChild gibt es dieses zusätzliche DOM-Element gar nicht – die Props landen direkt auf IconButton.
Event-Handler zusammenführen statt überschreiben
Die Implementierung oben hat einen Haken: ...children.props überschreibt einfach alle triggerProps mit gleichem Namen. Wenn sowohl der Tooltip als auch der Nutzer selbst einen onMouseEnter-Handler auf IconButton gesetzt haben, würde nur einer der beiden ausgeführt.
Radix löst das mit einer Merge-Funktion, die Handler mit gleichem Namen zu einem kombinierten Handler zusammenführt:
function mergeProps(triggerProps, childProps) {
const merged = { ...triggerProps, ...childProps };
for (const key in triggerProps) {
const isHandler = key.startsWith('on') && typeof triggerProps[key] === 'function';
if (isHandler && typeof childProps[key] === 'function') {
merged[key] = (...args) => {
triggerProps[key](...args);
childProps[key](...args);
};
}
}
return merged;
}Das ist der Grund, warum Radix intern eine eigene Slot-Komponente hat statt einfach nackt cloneElement einzusetzen: Sie kümmert sich um genau dieses Zusammenführen von Event-Handlern, dazu className (Strings werden zusammengefügt statt überschrieben), style und ref.
Klarstellung: asChild ist Konvention, kein React-Feature
Ein Punkt, der oft missverstanden wird: React kennt keine spezielle Prop namens asChild. Es gibt keinen internen Mechanismus dafür – der Name ist reine Community-Konvention, geprägt von Radix UI und mittlerweile übernommen von Ariakit, shadcn/ui und anderen. Die Prop könnte genauso gut renderAsChild heißen; React würde sie wie jede andere Prop behandeln.
Was React bereitstellt, sind nur die Bausteine, mit denen man das Verhalten selbst bauen kann: cloneElement, isValidElement, Children.only. Wer asChild nicht selbst implementieren will, kann Radix' fertige Lösung importieren:
import { Slot } from '@radix-ui/react-slot';
function TooltipTrigger({ asChild, children, ...triggerProps }) {
const Comp = asChild ? Slot : 'button';
return <Comp {...triggerProps}>{children}</Comp>;
}Slot verhält sich API-mäßig wie eine normale Komponente, macht intern aber genau das beschriebene Clone-und-Merge – inklusive Event-Handler-Merging und ref-Forwarding.
as vs. asChild im Vergleich
|
| |
|---|---|---|
Erzeugt eigenes DOM-Element | Ja, das angegebene Tag | Nein, nutzt das Kind-Element |
Funktioniert mit | HTML-Tags, einfache Komponenten | Beliebig komplexe Komponenten |
Typisierungsaufwand | Hoch (siehe Teil 1) | Geringer, keine Props-Weitergabe nötig |
Verbreitet bei | Chakra UI, Theme UI, Stitches | Radix UI, shadcn/ui, Ariakit |
Als Faustregel: as, wenn das Ziel ein austauschbares HTML-Tag ist; asChild, wenn das Ziel eine beliebige, bereits fertige Komponente sein soll.
Reale Anwendungsfälle
Typografie – der klassischste Fall. Visuelle Größe und semantische Hierarchie sind zwei unterschiedliche Dinge:
<Heading size="xl" as="h1">Seitentitel</Heading>
<Heading size="xl" as="h2">Sieht gleich aus, ist aber h2</Heading>Layout-Primitives – in Design-System-Bibliotheken oft die Basis für fast alles:
<Box as="nav" display="flex" gap={4}>...</Box>
<Box as="section" padding={8}>...</Box>Listen – wahlweise ul, ol, oder div mit role="list" für nicht-semantische Listen.
Karten, die entweder reiner Container oder ganz als Link zu einer Detailseite fungieren:
<Card as="article">...</Card>
<Card as="a" href="/produkt/123">...</Card>asChild typischerweise bei Trigger-Komponenten – Tooltip-, Dialog- oder Dropdown-Trigger, die sich exakt wie das übergebene Kind verhalten sollen, ohne zusätzliches DOM-Element.
Wann man es lieber lässt
Bei aller Nützlichkeit: Polymorphic Components sind kein Pattern, das man reflexhaft überall einsetzen sollte.
Barrierefreiheits-Falle: as macht es leicht, versehentlich falsches Markup zu erzeugen – <Heading as="div"> sieht visuell wie eine Überschrift aus, ist für Screenreader aber keine. Das Pattern verschiebt Verantwortung auf den Aufrufer, ohne sie zu erzwingen.
Schlechtere Discoverability: Welche Props gültig sind, hängt vom Wert einer anderen Prop ab. IDE-Autocomplete und Dokumentation werden dadurch weniger direkt – man muss erst as setzen, um zu sehen, was sonst noch geht.
Compile-Zeit-Kosten: Stark generische Typen mit verschachteltem Omit/ComponentProps können in großen Codebasen spürbar auf die TypeScript-Compile-Zeit gehen.
Team-Overhead: Für kleinere interne Projekte ist eine Handvoll konkreter Komponenten (LinkButton, Button) oft einfacher zu warten als ein generisches Polymorphic-System. Das Pattern lohnt sich vor allem bei Design-System-Code mit vielen Konsumenten – nicht bei jeder internen Komponente mit zwei Verwendungsstellen.
Fazit: Entscheidungshilfe
Ich würde Polymorphic Components anhand dieser Frage beurteilen:
Frage | Wenn ja |
|---|---|
Ist es dasselbe UI-Konzept? | Gut geeignet |
Bleiben Styling und grundlegendes Verhalten gleich? | Gut geeignet |
Muss nur das HTML-Element wechseln? | Sehr gut geeignet |
Geht es um semantisch korrektes HTML? | Sehr gut geeignet |
Ist es Teil eines Design-Systems? | Häufig sinnvoll |
Ändert sich die komplette Funktionalität? | Eher nicht |
Werden völlig unterschiedliche Components kombiniert? | Eher nicht |
Macht | Nicht verwenden |
Brauchst du die Flexibilität nur theoretisch? | Nicht verwenden |