frontend / component libraries / polymorphic_components.md

Polymorphic Components (the as prop)

4 min read source

Polymorphic Components (the as prop)

TL;DR

A polymorphic component can render as different elements/components via an as (or component) prop while keeping its own props and inferring the correct DOM props for whatever it renders. <Box as="a" href="…" /> renders an anchor and accepts href; <Box as="button" /> accepts onClick/disabled. The behavior is easy; typing it correctly in TypeScript (with forwardRef) is the hard, senior-level part. Radix’s asChild/Slot is the popular alternative that avoids the typing pain.

Interview Q&A

Q: What problem does as solve?

A: Reuse one component’s styling/behavior across different semantic elements without duplicating it. A design-system Button might need to be a real <a> for navigation (so it’s a link, keyboard-and-SEO-correct) but look identical. as lets one component cover both: <Button as="a" href="/x"> vs <Button onClick={…}>. It keeps semantics correct (../16_accessibility/02_semantic_html_and_landmarks.md) instead of slapping role on a <div>.

Q: How do you type a polymorphic component in TypeScript?

A: Generic over an ElementType, merging your own props with the rendered element’s props (minus collisions):

import { ElementType, ComponentPropsWithoutRef, ReactNode } from "react";

type BoxProps<E extends ElementType> = {
  as?: E;
  children?: ReactNode;
} & Omit<ComponentPropsWithoutRef<E>, "as" | "children">;

function Box<E extends ElementType = "div">({ as, ...rest }: BoxProps<E>) {
  const Tag = as ?? "div";
  return <Tag {...rest} />;
}

// usage — `href` is type-checked because as="a"
<Box as="a" href="/home">Home</Box>;
<Box as="button" onClick={() => {}} />;

ComponentPropsWithoutRef<E> pulls the valid props for E; Omit<…, "as"> prevents your props clashing with the element’s. Default the generic (= "div") so <Box> works with no as. See ../04_typescript/08_react_typing.md.

Q: How do you add ref forwarding to a polymorphic component?

A: This is the genuinely fiddly part — forwardRef doesn’t preserve generics well, so the ref type must come from ComponentPropsWithRef<E>["ref"] (or ElementRef<E>), and you cast the wrapped component to keep the generic call signature:

import { ElementType, ComponentPropsWithRef, forwardRef, Ref } from "react";

type Props<E extends ElementType> = { as?: E } & Omit<ComponentPropsWithRef<E>, "as">;

const Box = forwardRef(function Box<E extends ElementType = "div">(
  { as, ...rest }: Props<E>,
  ref: ComponentPropsWithRef<E>["ref"]
) {
  const Tag = as ?? "div";
  return <Tag ref={ref} {...rest} />;
}) as <E extends ElementType = "div">(p: Props<E> & { ref?: Ref<unknown> }) => JSX.Element;

Most teams copy a helper type (PolymorphicComponentPropsWithRef) or use a library type rather than rewrite this each time. (React 19 relaxes some of this — ref as a prop without forwardRef.)

Q: as prop vs Radix asChild?

A:

as prop asChild (Slot)
Mechanism component renders the element you name component merges its props onto your child
Typing complex generics for full safety simpler — your child is your element
Wrapper none none
Risk prop collisions, generic ref pain child must forward ref + spread props

Both avoid wrapper <div>s. asChild sidesteps the polymorphic-typing problem by letting you supply the concrete element. See headless_ui_and_radix.md.

Q: How do libraries do this? (MUI/Chakra)

A: MUI uses a component prop (its name for as) plus an sx/styled system; Chakra and styled-system use as. They ship the polymorphic types so consumers get prop inference. Knowing the pattern explains why <Button component={Link} to="/x" /> type-checks to.

Gotchas / edge cases

  • Prop collisions — if your component defines color and the element also has color, Omit which one? Decide and document; unmanaged collisions produce confusing type errors.
  • forwardRef erases the generic — without the cast/helper, as loses inference and ref is typed wrong. This is the single most common polymorphic-typing bug.
  • Runtime vs type safetyas={SomeComponent} that doesn’t accept the spread props fails at runtime even if loosely typed; keep the constraint tight.
  • Perf — a deeply polymorphic primitive used thousands of times adds a tiny indirection; negligible in practice, but don’t make everything polymorphic “just in case.”
  • Required props of the target — typing can’t always force href when as="a"; some setups make href optional. Be aware safety isn’t always total.

What a senior is expected to say

  • as lets one styled component render different semantic elements with correct prop inference — great for Button-as-link without breaking semantics.”
  • “The hard part is the TS: generic over ElementType, merge ComponentPropsWithoutRef<E> minus collisions, default the generic. Adding forwardRef erases the generic unless you cast or use a helper type.”
  • “Radix asChild/Slot is the simpler alternative — merge onto a concrete child instead of a polymorphic prop.”
  • “React 19’s ref-as-prop reduces the forwardRef ceremony.”

Cross-references

Further reading