alle Artikel
3 Minuten

Das asChild-Pattern und wann sich Polymorphic Components wirklich lohnen

Frank Lechner & KI ·
„Warum Radix, shadcn & Co. auf asChild statt as setzen – und wann du auf beides besser verzichtest.”
Das asChild-Pattern und wann sich Polymorphic Components wirklich lohnen
Dieser Text wurde mit Hilfe von KI erstellt.

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

as="button"

asChild

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 as die API komplizierter als das Problem?

Nicht verwenden

Brauchst du die Flexibilität nur theoretisch?

Nicht verwenden