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
colorand the element also hascolor,Omitwhich one? Decide and document; unmanaged collisions produce confusing type errors. forwardReferases the generic — without the cast/helper,asloses inference andrefis typed wrong. This is the single most common polymorphic-typing bug.- Runtime vs type safety —
as={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
hrefwhenas="a"; some setups makehrefoptional. Be aware safety isn’t always total.
What a senior is expected to say
- “
aslets 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, mergeComponentPropsWithoutRef<E>minus collisions, default the generic. AddingforwardReferases 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
forwardRefceremony.”
Cross-references
- React typing patterns: ../04_typescript/08_react_typing.md, ../04_typescript/01_generics.md
asChild/Slot alternative: headless_ui_and_radix.md- Semantic correctness (
as="a"vsrole): ../16_accessibility/02_semantic_html_and_landmarks.md
Further reading
- “How to build polymorphic React components with TypeScript” (Ohans Emmanuel): https://www.benmvp.com/blog/polymorphic-react-components-typescript/
- Radix
Slot/asChild: https://www.radix-ui.com/primitives/docs/utilities/slot - React 19 —
refas a prop: https://react.dev/blog/2024/12/05/react-19#ref-as-a-prop