Polymorfism

Hur du använder `as`-prop:en för att ändra det renderade HTML-elementet samtidigt som komponentens funktionalitet bevaras.

as-prop:en är ett grundläggande mönster i moderna React-komponentbibliotek som låter dig ändra det underliggande HTML-elementet eller komponenten som renderas.

Populärgjort av bibliotek som Styled Components, Emotion, och Chakra UI, ger detta mönster flexibilitet att välja semantisk HTML samtidigt som komponentens styling och beteende bevaras.

as-prop:en möjliggör polymorfa komponenter - komponenter som kan rendera som olika elementtyper samtidigt som deras kärnfunktionalitet bevaras:

<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>

Förstå as

as-prop:en låter dig åsidosätta en komponents standardelementtyp. Istället för att vara låst till ett specifikt HTML-element kan du anpassa komponenten så att den renderar som vilken giltig HTML-tagg som helst eller till och med en annan React-komponent.

Till exempel:

// 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>

Detta renderar olika HTML-element:

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

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

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

Implementeringsmetoder

Det finns två huvudsakliga tillvägagångssätt för att implementera polymorfa komponenter: en manuell implementering och att använda Radix UIs Slot-komponent.

Manuell implementering

as-prop:en implementeras med dynamisk komponentrendering:

// 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>;
}

Komponenten:

  1. Accepterar en as-prop med en standardelementtyp
  2. Använder det tillhandahållna elementet eller faller tillbaka till standard
  3. Sprider alla andra props till det renderade elementet
  4. Bibehåller typesäkerhet med TypeScript-generics

Att använda Radix UI Slot

Radix UI tillhandahåller en Slot-komponent som erbjuder ett kraftfullare alternativ till as-prop-mönstret. Istället för att bara ändra elementtypen slår Slot ihop props med barnelementet, vilket möjliggör kompositionsmönster.

Först, installera paketet:

npm install @radix-ui/react-slot

asChild-mönstret använder en boolean-prop istället för att ange elementtyp:

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}
    />
  )
}

Nu kan du använda det på två sätt:

// 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>

Slot-komponenten:

  1. Klonar barnelementet
  2. Slår ihop komponentens props (className, data-attribut, osv.) med barnets props
  3. Vidarebefordrar refs korrekt
  4. Hanterar sammansättning av eventhanterare

Jämförelse: as vs asChild

as-prop (manuell implementering):

// 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 med 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

Viktiga skillnader:

Funktionas propasChild + Slot
API-stil<Button as="a"><Button asChild><a /></Button>
ElementtypSpecificerad i propHärledd från barnet
KomponentkompositionBegränsadFullt stöd
Sammanfogning av propsGrundläggande spridningIntelligent sammanslagning
Ref-vidarebefordranKräver manuell setupInbyggt
HändelsehanterareKan krockaSammansätts korrekt
BiblioteksstorlekIngen beroendeKräver @radix-ui/react-slot

När man ska använda varje metod

Använd as-prop när:

  • Du vill ha ett enklare API
  • Du främst byter mellan HTML-element
  • Du vill undvika ytterligare beroenden
  • Komponenten är enkel och behöver inte komplex prop-sammanslagning

Använd asChild + Slot när:

  • Du behöver komponera med andra komponenter
  • Du vill ha automatisk prop-sammanslagning
  • Du bygger ett komponentbibliotek liknande Radix UI eller shadcn/ui
  • Du behöver pålitlig ref-vidarebefordran över olika komponenttyper

Viktiga fördelar

1. Semantisk HTML-flexibilitet

as-prop:en säkerställer att du alltid kan använda det mest semantiskt lämpliga HTML-elementet:

// 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. Återanvändbarhet av komponenter

En komponent kan tjänstgöra flera syften utan att skapa varianter:

// 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. Förbättringar av tillgänglighet

Välj element som ger bäst tillgänglighet för varje kontext:

// 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. Integrering med stylesystem

Behåll konsekvent styling samtidigt som du byter element:

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>

Vanliga användningsfall

Typografikomponenter

Skapa flexibla textkomponenter:

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>

Layoutkomponenter

Bygg semantiska layouter:

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>

Interaktiva element

Hantera olika interaktionstyper:

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 bästa praxis

Generiska komponenttyper

Skapa fullt typesäkra polymorfa komponenter:

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} />;
}

Härledning av props

Automatiskt härleda props baserat på elementet:

// 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

Diskriminerade unioner

Använd diskriminerade unioner för element-specifika 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} />;
}

Bästa praxis

1. Standardisera till semantiska element

Välj meningsfulla standarder som representerar det vanligaste användningsfallet:

// ✅ 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. Dokumentera giltiga element

Specificera tydligt vilka element som stöds:

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

3. Validera elements lämplighet

Varn a när olämpliga element används:

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. Hantera eventhanterare korrekt

Säkerställ att eventhanterare fungerar över olika element:

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}
    />
  );
}

Vanliga fallgropar

Ogiltig HTML-nestning

Var försiktig med HTML-nestningsregler:

// ❌ 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>

Saknade tillgänglighetsattribut

Kom ihåg att lägga till lämpliga ARIA-attribut:

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

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

Förlust av typesäkerhet

Undvik att använda alltför generösa typer:

// ❌ 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} />;
}

Prestandaöverväganden

Var medveten om konsekvenserna för omrendering:

// ❌ 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} />;
}