Polymorphic Components in React – Das as-Pattern von Grund auf
)
Das Problem: Ein Button, der ein Link sein soll
Jedes UI-Komponenten-System stößt früher oder später auf dieselbe Situation: Du hast einen Button mit deinem Styling – Farbe, Padding, Hover-Effekt –, aber manchmal soll dieser "Button" eigentlich ein <a> sein. Zum Beispiel "Zum Warenkorb", das auf eine andere Seite verlinkt, aber optisch identisch zum Button aussehen soll.
Die naive Lösung: zwei Komponenten, die das gleiche Styling duplizieren.
function ButtonAsButton({ children, onClick }) {
return <button className="btn" onClick={onClick}>{children}</button>;
}
function ButtonAsLink({ children, href }) {
return <a className="btn" href={href}>{children}</a>;
}Funktioniert, aber jede Styling-Änderung muss an zwei Stellen gepflegt werden. Genau dieses Problem lösen Polymorphic Components: eine Komponente, die selbst entscheiden lässt, als welches HTML-Element sie gerendert wird.
Die einfachste Lösung: das as-Prop in reinem JavaScript
Der Kern des Patterns lässt sich in drei Zeilen zeigen – noch ganz ohne TypeScript:
function Button({ as, children, ...rest }) {
const Component = as || 'button';
return <Component className="btn" {...rest}>{children}</Component>;
}Der Trick, den man einmal verstanden haben muss: Component ist eine Variable, die auf einen String wie 'a' oder 'button' zeigt. JSX erlaubt es, eine Variable direkt als Tag zu verwenden – solange sie großgeschrieben ist. Kleingeschriebene Tags interpretiert React als natives HTML-Element, großgeschriebene als Variable bzw. Komponente. Deshalb Component, nicht component.
Verwendung:
<Button onClick={() => alert('Hi')}>Klick mich</Button>
// → <button class="btn">Klick mich</button>
<Button as="a" href="/warenkorb">Zum Warenkorb</Button>
// → <a class="btn" href="/warenkorb">Zum Warenkorb</a>Alle Props außer as werden per Spread einfach durchgereicht. Mehr passiert zur Laufzeit nicht – die eigentliche Komplexität kommt erst mit TypeScript.
TypeScript ins Spiel bringen: ElementType und ComponentProps<T>
Das Ziel: Wenn as="a" gesetzt ist, soll TypeScript automatisch href kennen und anbieten; bei as="button" entsprechend onClick, disabled – inklusive Fehlermeldung, wenn man eine Prop verwendet, die das jeweilige Element gar nicht hat.
import { ElementType, ComponentProps } from 'react';
type ButtonProps<T extends ElementType> = {
as?: T;
children: React.ReactNode;
} & ComponentProps<T>;
function Button<T extends ElementType = 'button'>({
as,
children,
...rest
}: ButtonProps<T>) {
const Component = as || 'button';
return <Component className="btn" {...rest}>{children}</Component>;
}Die entscheidenden Teile:
T extends ElementType–Tist ein Platzhalter für "irgendein Element-Typ" ('a','button','div', aber auch eigene Komponenten).ElementTypeist der React-Typ, der das beschreibt.ComponentProps<T>– der eigentliche Trick. Er sagt TypeScript: "Nimm alle Props, die das ElementTnormalerweise hat." BeiT = 'a'bekommst duhref,target; beiT = 'button'bekommst duonClick,disabled.T extends ElementType = 'button'in der Funktionssignatur – der Default, analog zuas || 'button'zur Laufzeit.
Der Effekt:
<Button onClick={() => {}}>Klick</Button> // ✅ onClick kommt von <button>
<Button as="a" href="/x">Link</Button> // ✅ href kommt von <a>
<Button as="a" disabled>Link</Button> // ❌ Fehler: <a> hat kein disabledTypeScript weiß abhängig vom as-Wert, welche Props gültig sind – das ist der eigentliche Nutzen gegenüber der reinen JavaScript-Version.
Eigene Props sauber einmischen
Reale Komponenten haben fast immer eigene Props, z. B. eine variant-Prop. Naiv hinzugefügt sieht das so aus:
type ButtonProps<T extends ElementType> = {
as?: T;
variant?: 'primary' | 'secondary';
children: React.ReactNode;
} & ComponentProps<T>;Das Problem: ComponentProps<T> bringt alle Props des Elements mit. Falls T zufällig selbst eine Prop namens variant hätte (bei anderen React-Komponenten als as-Wert durchaus möglich), entsteht ein Typkonflikt – TypeScript weiß nicht, welche Definition gelten soll.
Die saubere Lösung: eigene Props zuerst definieren, dann alles, was kollidiert, aus den nativen Props herausfiltern.
import { ElementType, ComponentProps } from 'react';
// 1. Nur die Props, die wir selbst erfinden
type OwnProps = {
variant?: 'primary' | 'secondary';
children: React.ReactNode;
};
// 2. Eigene Props + native Props, aber Kollisionen entfernt
type ButtonProps<T extends ElementType> = OwnProps &
Omit<ComponentProps<T>, keyof OwnProps> & {
as?: T;
};
function Button<T extends ElementType = 'button'>({
as,
variant = 'primary',
children,
...rest
}: ButtonProps<T>) {
const Component = as || 'button';
const className = variant === 'primary' ? 'btn btn-primary' : 'btn btn-secondary';
return (
<Component className={className} {...rest}>
{children}
</Component>
);
}Omit<ComponentProps<T>, keyof OwnProps> heißt: "Nimm die nativen Props von T, aber wirf jede Prop raus, deren Name auch in OwnProps vorkommt." Damit gewinnt unsere eigene variant-Definition immer, egal was das Element sonst mitbringt.
<Button variant="primary" onClick={() => {}}>Kaufen</Button>
<Button as="a" variant="secondary" href="/warenkorb">Zum Warenkorb</Button>
<Button as="a" variant="secondary" disabled>Kaputt</Button>
// ❌ Fehler: disabled gibt es bei <a> nichtMerksatz: Erst eigene Props als eigenständigen Typ definieren, dann die nativen Props des as-Elements dazumischen und dabei alles herausfiltern, was schon eigen ist. Diese Reihenfolge ist der Kernkniff, der bei so gut wie jeder Polymorphic-Component-Implementierung wiederkehrt – auch bei Chakra UI oder Radix läuft es im Kern auf genau dieses Muster hinaus.
Der unschöne Teil: typsicheres ref-Forwarding
Ein Punkt bleibt in den bisherigen Beispielen offen: ref. Der Typ von ref hängt ebenfalls vom as-Element ab – bei as="a" ist es HTMLAnchorElement, bei as="button" HTMLButtonElement. Hier stoßen zwei React/TypeScript-Eigenheiten aneinander: forwardRef erwartet feste, keine generischen Typen.
Der Standard-Workaround ist ein Type-Cast auf die fertige Komponente:
import { ElementType, ComponentPropsWithRef, forwardRef } from 'react';
type PolymorphicRef<T extends ElementType> = ComponentPropsWithRef<T>['ref'];
type ButtonProps<T extends ElementType> = OwnProps &
Omit<ComponentProps<T>, keyof OwnProps> & {
as?: T;
ref?: PolymorphicRef<T>;
};
const Button = forwardRef(
<T extends ElementType = 'button'>(
{ as, variant = 'primary', children, ...rest }: ButtonProps<T>,
ref: PolymorphicRef<T>
) => {
const Component = as || 'button';
const className = variant === 'primary' ? 'btn btn-primary' : 'btn btn-secondary';
return (
<Component ref={ref} className={className} {...rest}>
{children}
</Component>
);
}
) as <T extends ElementType = 'button'>(props: ButtonProps<T>) => JSX.Element;Der letzte as-Cast ist hässlich, aber notwendig: Ohne ihn verliert TypeScript die Generic-Information von forwardRef und ref wird pauschal auf einen festen Typ eingeschränkt. In der Praxis kopiert man diesen Block einmal und fasst ihn nicht mehr an.
Kleiner Hinweis für die Gegenwart: Seit React 19 ist ref eine ganz normale Prop, forwardRef ist nicht mehr zwingend nötig. Das macht die generische Typisierung deutlich einfacher, weil ref direkt im eigenen Props-Typ landet, ohne den Cast-Hack oben. Für Codebasen, die schon auf React 19 sind, lohnt sich also ein zweiter Blick, bevor man den forwardRef-Umweg überhaupt einbaut.
Wer eine voll getypte polymorphe Komponente live in Aktion sehen möchte, findet unter dem folgenden Link ein Beispiel auf StackBlitz:
Eine polymorphe Komponente in Aktion
Viel Spaß beim Experimentieren!
Zwischenfazit
Mit as-Prop, ComponentProps<T>, dem Omit-Kniff für eigene Props und typsicherem ref-Forwarding hast du das Handwerkszeug für den häufigsten Fall: ein Element gegen ein anderes HTML-Tag austauschen. Aber was, wenn das, was du gegen den Button austauschen willst, gar kein simples HTML-Tag ist, sondern schon eine fertige, komplexe Komponente mit eigener Logik? Genau da versagt das as-Pattern – und genau da setzt Radix UI mit einem anderen Ansatz an: asChild. Dazu mehr in Teil 2.