Polymorphismus

Wie man die `as`-Prop verwendet, um das gerenderte HTML-Element zu ändern und gleichzeitig die Funktionalität der Komponente beizubehalten.

Die as-Prop ist ein grundlegendes Muster in modernen React-Komponentenbibliotheken, das es ermöglicht, das zugrunde liegende HTML-Element oder die Komponente zu ändern, die gerendert wird.

Popularisiert durch Bibliotheken wie Styled Components, Emotion, und Chakra UI, bietet dieses Muster die Flexibilität, semantisches HTML zu wählen und gleichzeitig Styling und Verhalten der Komponente beizubehalten.

Die as-Prop ermöglicht polymorphe Komponenten - Komponenten, die als verschiedene Elementtypen gerendert werden können, während ihre Kernfunktionalität erhalten bleibt:

<Button as="a" href="/home">
  Go Home
</Button>

<Button as="button" type="submit">
  Submit Form
</Button>

<Button as="div" role="button" tabIndex={0}>
  Custom Element
</Button>

Verständnis von as

Die as-Prop erlaubt es Ihnen, den standardmäßigen Elementtyp einer Komponente zu überschreiben. Anstatt auf ein bestimmtes HTML-Element festgelegt zu sein, können Sie die Komponente so anpassen, dass sie als jedes gültige HTML-Tag oder sogar als eine andere React-Komponente gerendert wird.

Zum Beispiel:

// Default renders as a div
<Box>Content</Box>

// Renders as a section
<Box as="section">Content</Box>

// Renders as a nav
<Box as="nav">Content</Box>

Das rendert unterschiedliche HTML-Elemente:

<!-- Default -->
<div>Content</div>

<!-- With as="section" -->
<section>Content</section>

<!-- With as="nav" -->
<nav>Content</nav>

Implementierungsansätze

Es gibt zwei Hauptansätze zur Implementierung polymorpher Komponenten: eine manuelle Implementierung und die Verwendung des Slot-Components von Radix UI.

Manuelle Implementierung

Die Implementierung der as-Prop verwendet dynamisches Component-Rendering:

// Simplified implementation
function Component({
  as: Element = 'div',
  children,
  ...props
}) {
  return <Element {...props}>{children}</Element>;
}

// More complete implementation with TypeScript
type PolymorphicProps<E extends React.ElementType> = {
  as?: E;
  children?: React.ReactNode;
} & React.ComponentPropsWithoutRef<E>;

function Component<E extends React.ElementType = 'div'>({
  as,
  children,
  ...props
}: PolymorphicProps<E>) {
  const Element = as || 'div';
  return <Element {...props}>{children}</Element>;
}

Die Komponente:

  1. Akzeptiert eine as-Prop mit einem Standard-Elementtyp
  2. Verwendet das angegebene Element oder fällt auf das Standard zurück
  3. Spreadet alle anderen Props auf das gerenderte Element
  4. Bewahrt Typensicherheit durch TypeScript-Generics

Verwendung von Radix UI Slot

Radix UI stellt ein Slot-Component zur Verfügung, das eine leistungsfähigere Alternative zum as-Prop-Muster bietet. Anstatt nur den Elementtyp zu ändern, verbindet Slot Props mit der Kindkomponente und ermöglicht damit Kompositionsmuster.

Installieren Sie zuerst das Paket:

npm install @radix-ui/react-slot

Das asChild-Muster verwendet eine boolean-Prop anstelle der Angabe des Elementtyps:

import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"

const itemVariants = cva(
  "rounded-lg border p-4",
  {
    variants: {
      variant: {
        default: "bg-white",
        primary: "bg-blue-500 text-white",
      },
      size: {
        default: "h-10 px-4",
        sm: "h-8 px-3",
        lg: "h-12 px-6",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

function Item({
  className,
  variant = "default",
  size = "default",
  asChild = false,
  ...props
}: React.ComponentProps<"div"> &
  VariantProps<typeof itemVariants> & { asChild?: boolean }) {
  const Comp = asChild ? Slot : "div"
  return (
    <Comp
      data-slot="item"
      data-variant={variant}
      data-size={size}
      className={cn(itemVariants({ variant, size, className }))}
      {...props}
    />
  )
}

Jetzt können Sie es auf zwei Arten verwenden:

// Default: renders as a div
<Item variant="primary">Content</Item>

// With asChild: merges props with child component
<Item variant="primary" asChild>
  <a href="/home">Link with Item styles</a>
</Item>

Das Slot-Component:

  1. Klont das Kindelement
  2. Verschmilzt die Props der Komponente (className, data-Attribute, etc.) mit den Props des Kindes
  3. Forwardet refs korrekt
  4. Handhabt die Komposition von Event-Handlern

Vergleich: as vs asChild

as prop (manuelle Implementierung):

// Explicit element type
<Button as="a" href="/home">Link Button</Button>
<Button as="button" type="submit">Submit Button</Button>

// Simple, predictable API
// Limited to element types

asChild with Slot:

// Implicit from child
<Button asChild>
  <a href="/home">Link Button</a>
</Button>

<Button asChild>
  <button type="submit">Submit Button</button>
</Button>

// More flexible composition
// Works with any component
// Better prop merging

Wesentliche Unterschiede:

Merkmalas propasChild + Slot
API-Stil<Button as="a"><Button asChild><a /></Button>
ElementtypIm Prop angegebenVom Kind abgeleitet
KomponentenkompositionEingeschränktVolle Unterstützung
Props-ZusammenführungEinfaches SpreadIntelligente Verschmelzung
Ref-WeiterleitungManuelle Einrichtung nötigIntegriert
EreignishandlerKönnen konfligierenWerden korrekt komponiert
BibliotheksgrößeKeine AbhängigkeitErfordert @radix-ui/react-slot

Wann Sie welchen Ansatz verwenden sollten

Verwenden Sie die as-Prop, wenn:

  • Sie eine einfachere API wollen
  • Sie hauptsächlich zwischen HTML-Elementen wechseln
  • Sie zusätzliche Abhängigkeiten vermeiden möchten
  • Die Komponente einfach ist und keine komplexe Props-Verschmelzung benötigt

Verwenden Sie asChild + Slot, wenn:

  • Sie mit anderen Komponenten komponieren müssen
  • Sie automatische Props-Verschmelzung wünschen
  • Sie eine Komponentenbibliothek ähnlich Radix UI oder shadcn/ui bauen
  • Sie zuverlässiges Ref-Forwarding über verschiedene Komponententypen hinweg benötigen

Hauptvorteile

1. Semantische HTML-Flexibilität

Die as-Prop stellt sicher, dass Sie immer das semantisch passendste HTML-Element verwenden können:

// Navigation container
<Container as="nav" className="navigation">
  <NavItems />
</Container>

// Main content area
<Container as="main" className="content">
  <Article />
</Container>

// Sidebar
<Container as="aside" className="sidebar">
  <Widgets />
</Container>

2. Wiederverwendbarkeit von Komponenten

Eine Komponente kann mehrere Zwecke erfüllen, ohne Varianten erstellen zu müssen:

// Text component used for different elements
<Text as="h1" size="2xl">Page Title</Text>
<Text as="p" size="md">Body paragraph</Text>
<Text as="span" size="sm">Inline text</Text>
<Text as="label" size="sm">Form label</Text>

3. Verbesserte Zugänglichkeit

Wählen Sie Elemente, die den besten Zugang für den jeweiligen Kontext bieten:

// Link that looks like a button
<Button as="a" href="/signup">
  Sign Up Now
</Button>

// Button that submits a form
<Button as="button" type="submit">
  Submit
</Button>

// Heading with button styles
<Button as="h2" role="presentation">
  Section Title
</Button>

4. Integration ins Stylesystem

Behalten Sie konsistente Styles bei, während Sie Elemente ändern:

const Card = styled.div`
  padding: 1rem;
  border-radius: 8px;
  background: white;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
`;

// Same styles, different elements
<Card as="article">Article content</Card>
<Card as="section">Section content</Card>
<Card as="li">List item content</Card>

Häufige Anwendungsfälle

Typografie-Komponenten

Erstellen Sie flexible Textkomponenten:

function Text({
  as: Element = 'span',
  variant = 'body',
  ...props
}) {
  const className = cn(
    'text-base',
    variant === 'heading' && 'text-2xl font-bold',
    variant === 'body' && 'text-base',
    variant === 'caption' && 'text-sm text-gray-600',
    props.className
  );

  return <Element className={className} {...props} />;
}

// Usage
<Text as="h1" variant="heading">Title</Text>
<Text as="p" variant="body">Paragraph</Text>
<Text as="figcaption" variant="caption">Caption</Text>

Layout-Komponenten

Erstellen Sie semantische Layouts:

function Flex({ as: Element = 'div', ...props }) {
  return (
    <Element
      className={cn('flex', props.className)}
      {...props}
    />
  );
}

// Semantic HTML
<Flex as="header" className="justify-between">
  <Logo />
  <Navigation />
</Flex>

<Flex as="main" className="flex-col">
  <Content />
</Flex>

Interaktive Elemente

Behandeln Sie verschiedene Interaktionstypen:

function Clickable({ as: Element = 'button', ...props }) {
  const isButton = Element === 'button';
  const isAnchor = Element === 'a';

  return (
    <Element
      role={!isButton && !isAnchor ? 'button' : undefined}
      tabIndex={!isButton && !isAnchor ? 0 : undefined}
      {...props}
    />
  );
}

// Various clickable elements
<Clickable as="button" onClick={handleClick}>Button</Clickable>
<Clickable as="a" href="/link">Link</Clickable>
<Clickable as="div" onClick={handleClick}>Div Button</Clickable>

TypeScript Best Practices

Generische Komponententypen

Erstellen Sie vollständig typsichere polymorphe Komponenten:

type PolymorphicRef<E extends React.ElementType> =
  React.ComponentPropsWithRef<E>['ref'];

type PolymorphicProps<
  E extends React.ElementType,
  Props = {}
> = Props &
  Omit<React.ComponentPropsWithoutRef<E>, keyof Props> & {
    as?: E;
  };

// Component with full type safety
function Component<E extends React.ElementType = 'div'>({
  as,
  ...props
}: PolymorphicProps<E, { customProp?: string }>) {
  const Element = as || 'div';
  return <Element {...props} />;
}

Props ableiten

Leiten Sie Props automatisch basierend auf dem Element ab:

// Props are inferred from the element type
<Component as="a" href="/home">Home</Component>  // ✅ href is valid
<Component as="div" href="/home">Home</Component> // ❌ TS error: href not valid on div

<Component as="button" type="submit">Submit</Component> // ✅ type is valid
<Component as="span" type="submit">Submit</Component>   // ❌ TS error

Diskriminierte Unions

Verwenden Sie discriminated unions für element-spezifische Props:

type ButtonProps =
  | { as: 'button'; type?: 'submit' | 'button' | 'reset' }
  | { as: 'a'; href: string; target?: string }
  | { as: 'div'; role: 'button'; tabIndex: number };

function Button(props: ButtonProps & { children: React.ReactNode }) {
  const Element = props.as;
  return <Element {...props} />;
}

Beste Vorgehensweisen

1. Standardmäßig semantische Elemente verwenden

Wählen Sie sinnvolle Defaults, die den häufigsten Anwendungsfall repräsentieren:

// ✅ Good defaults
function Article({ as: Element = 'article', ...props }) { }
function Navigation({ as: Element = 'nav', ...props }) { }
function Heading({ as: Element = 'h2', ...props }) { }

// ❌ Too generic
function Component({ as: Element = 'div', ...props }) { }

2. Gültige Elemente dokumentieren

Geben Sie klar an, welche Elemente unterstützt werden:

interface BoxProps {
  /**
   * The HTML element to render as
   * @default 'div'
   * @example 'section', 'article', 'aside', 'main'
   */
  as?: 'div' | 'section' | 'article' | 'aside' | 'main' | 'header' | 'footer';
}

3. Angemessenheit der Elemente validieren

Warnen Sie, wenn ungeeignete Elemente verwendet werden:

function Button({ as: Element = 'button', ...props }) {
  if (__DEV__ && Element === 'div' && !props.role) {
    console.warn(
      'Button: When using as="div", provide role="button" for accessibility'
    );
  }

  return <Element {...props} />;
}

4. Ereignishandler korrekt behandeln

Stellen Sie sicher, dass Event-Handler über verschiedene Elemente hinweg funktionieren:

function Interactive({ as: Element = 'button', onClick, ...props }) {
  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (Element !== 'button' && (e.key === 'Enter' || e.key === ' ')) {
      onClick?.(e as any);
    }
  };

  return (
    <Element
      onClick={onClick}
      onKeyDown={Element !== 'button' ? handleKeyDown : undefined}
      {...props}
    />
  );
}

Häufige Fallstricke

Ungültige HTML-Verschachtelung

Achten Sie auf HTML-Verschachtelungsregeln:

// ❌ Invalid - button inside button
<Button as="button">
  <Button as="button">Nested</Button>
</Button>

// ❌ Invalid - div inside p
<Text as="p">
  <Box as="div">Invalid nesting</Box>
</Text>

// ✅ Valid nesting
<Text as="div">
  <Box as="div">Valid nesting</Box>
</Text>

Fehlende Zugänglichkeitsattribute

Denken Sie daran, passende ARIA-Attribute hinzuzufügen:

// ❌ Missing accessibility
<Box as="nav">
  <MenuItems />
</Box>

// ✅ Proper accessibility
<Box as="nav" aria-label="Main navigation">
  <MenuItems />
</Box>

Verlust der Typensicherheit

Vermeiden Sie zu permissive Typen:

// ❌ Too permissive - no type safety
function Component({ as: Element = 'div', ...props }: any) {
  return <Element {...props} />;
}

// ✅ Type safe
function Component<E extends React.ElementType = 'div'>({
  as,
  ...props
}: PolymorphicProps<E>) {
  const Element = as || 'div';
  return <Element {...props} />;
}

Leistungsaspekte

Beachten Sie mögliche Re-Render-Implikationen:

// ❌ Creates new component on every render
function Parent() {
  const CustomDiv = (props) => <div {...props} />;
  return <Component as={CustomDiv} />;
}

// ✅ Stable component reference
const CustomDiv = (props) => <div {...props} />;
function Parent() {
  return <Component as={CustomDiv} />;
}