Polimorfismo

Cómo usar la propiedad `as` para cambiar el elemento HTML renderizado mientras se preserva la funcionalidad del componente.

La propiedad as es un patrón fundamental en las bibliotecas modernas de componentes de React que te permite cambiar el elemento HTML subyacente o el componente que se renderiza.

Popularizado por bibliotecas como Styled Components, Emotion, y Chakra UI, este patrón proporciona flexibilidad para elegir HTML semántico mientras se mantienen el estilo y el comportamiento del componente.

La propiedad as permite componentes polimórficos: componentes que pueden renderizarse como distintos tipos de elemento mientras preservan su funcionalidad principal:

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

Comprendiendo as

La propiedad as te permite sobrescribir el tipo de elemento por defecto de un componente. En lugar de estar limitado a un elemento HTML específico, puedes adaptar el componente para que se renderice como cualquier etiqueta HTML válida o incluso como otro componente de React.

Por ejemplo:

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

Esto renderiza diferentes elementos HTML:

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

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

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

Métodos de implementación

Hay dos enfoques principales para implementar componentes polimórficos: una implementación manual y el uso del componente Slot de Radix UI.

Implementación manual

La implementación de la propiedad as usa renderizado dinámico de componentes:

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

El componente:

  1. Acepta una propiedad as con un tipo de elemento por defecto
  2. Usa el elemento proporcionado o hace fallback al predeterminado
  3. Expande todas las demás props al elemento renderizado
  4. Mantiene la seguridad de tipos con genéricos de TypeScript

Uso de Radix UI Slot

Radix UI proporciona un componente Slot que ofrece una alternativa más potente al patrón de la propiedad as. En lugar de solo cambiar el tipo de elemento, Slot fusiona las props con el componente hijo, habilitando patrones de composición.

Primero, instala el paquete:

npm install @radix-ui/react-slot

El patrón asChild usa una propiedad booleana en lugar de especificar el tipo de elemento:

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

Ahora puedes usarlo de dos maneras:

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

El componente Slot:

  1. Clona el elemento hijo
  2. Fusiona las props del componente (className, atributos data, etc.) con las props del hijo
  3. Reenvía refs correctamente
  4. Maneja la composición de event handlers

Comparación: as vs asChild

Propiedad as (implementación manual):

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

Diferencias clave:

Característicaas propasChild + Slot
Estilo de API<Button as="a"><Button asChild><a /></Button>
Tipo de elementoEspecificado en la propInferido del hijo
Composición de componentesLimitadaSoporte completo
Fusión de propsPropagación básicaFusión inteligente
Reenvío de refsRequiere configuración manualIntegrado
Manejadores de eventosPueden entrar en conflictoComposición correcta
Tamaño de la libreríaSin dependenciaRequiere @radix-ui/react-slot

Cuándo usar cada enfoque

Usa la propiedad as cuando:

  • Quieras una superficie de API más simple
  • Principalmente cambies entre elementos HTML
  • Quieras evitar dependencias adicionales
  • El componente sea simple y no necesite fusión compleja de props

Usa asChild + Slot cuando:

  • Necesites componer con otros componentes
  • Quieras comportamiento de fusión automática de props
  • Estés creando una biblioteca de componentes similar a Radix UI o shadcn/ui
  • Necesites reenvío de refs confiable entre distintos tipos de componentes

Beneficios clave

1. Flexibilidad para HTML semántico

La propiedad as asegura que siempre puedas usar el elemento HTML más semánticamente apropiado:

// 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. Reutilización de componentes

Un componente puede servir para múltiples propósitos sin crear variantes:

// 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. Mejoras en accesibilidad

Elige elementos que proporcionen la mejor accesibilidad para cada contexto:

// 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. Integración con sistemas de estilo

Mantén estilos consistentes mientras cambias elementos:

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>

Casos de uso comunes

Componentes de tipografía

Crea componentes de texto flexibles:

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>

Componentes de layout

Construye layouts semánticos:

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>

Elementos interactivos

Maneja distintos tipos de interacción:

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>

Buenas prácticas en TypeScript

Tipos genéricos para componentes

Crea componentes polimórficos totalmente seguros en tipos:

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

Inferencia de props

Infiera automáticamente las props basadas en el elemento:

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

Uniones discriminadas

Usa uniones discriminadas para props específicas de cada elemento:

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

Mejores prácticas

1. Predeterminar elementos semánticos

Elige valores por defecto significativos que representen el caso de uso más común:

// ✅ 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. Documentar elementos válidos

Especifica claramente qué elementos son compatibles:

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

3. Validar la idoneidad del elemento

Advertir cuando se usan elementos inapropiados:

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. Manejar correctamente los event handlers

Asegura que los event handlers funcionen en distintos elementos:

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

Errores comunes

Anidamiento HTML inválido

Ten cuidado con las reglas de anidamiento HTML:

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

Atributos de accesibilidad faltantes

Recuerda añadir los atributos ARIA apropiados:

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

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

Pérdida de seguridad de tipos

Evita usar tipos excesivamente permisivos:

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

Consideraciones de rendimiento

Ten en cuenta las implicaciones de re-renderizado:

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