Polymorphic Components
A polymorphic component lets you change the underlying element that it renders by passing it as a property. For example, a Text
component may render a p
element by default, but you can change it to any other element or component:
// Renders a <p>
<Text>Hello World</Text>
// Renders a <div>
<Text as="div">Hello World</Text>
// Renders the Paragraph component
<Text as={Paragraph}>Hello World</Text>
Passing an element or component via as
prop affects what other properties you are allowed use:
// Not valid!
// A button does not accept an href attribute
<Button href="/demo">This is a button</button>
// Valid
<Button as="a" href="/demo">This is a link</Button>
The Button
component renders a button
element by default and therefore does not accept an href
prop. But if you change it to an <a>
element, all properties of that HTML element are allowed.
Polymorphic components are pretty common, many libraries and design systems use them. From CSS-in-JS library like Styled Components and Stitches, through component libraries like Chakra UI and MUI, to design systems like React Spectrum and Atlassian Design System.
Problems
While polymorphic components provide some great flexibility to customize them, they're not without problems.
Type-checking performance: Creating types for a polymorphic component is quite complex if you want to make it right and support refs. Not just for developers writing them but also for TypeScript itself to type-check the components, which may result in a bad performance.
Chaining components: It's not possible to chain the as
property. For example, you use a Tooltip
component and pass the Button
component via as
prop. That works, but what if the button should actually be rendered as a link? While the Button
component accepts an as
prop, you can't use it here.
// Doesn't work, `as` can only be used once
<Tooltip as={Button} as="a" />
Props compatibility: While type-safety overall is pretty great, there are limitations regarding compatibility of props. Let's say you are using the CSS-in-JS library styled-components to style your elements. It accepts an as
property to customize the underlying element:
<StyledButton as={MyButton}>Click me</StyledButton>
The StyledButton
component will render the MyButton
component and pass the className (to apply the button styling) to it. What if MyButton
doesn't accept a className
property? TypeScript doesn't care about that - no error, no warning, not even a runtime error, it just doesn't work.
It's even worse for custom properties with incompatible prop types. What if the parent component and the component provided via as
have a property with the same name but accept different values?
<Button as={Flex} size="md" />
The Button
component and the Flex
component may both accept a size
property, but the allowed values may be different. This is not handled by TypeScript.
TypeScript v5: The recently released version 5 of TypeScript further improves type-checking, which breaks some usages of polymorphic components, more precisely when forwarding props inside a polymorphic component to another polymorphic component. The following example (types omitted) doesn't work anymore when using TypeScript 5.
const Stack = (props) => {
const { size, ...propsToForward } = props;
return <Box ...{propsToForward} />
};
The Stack
component accepts a size
property which isn't forwarded to the Box
component. But what if the underlying component, the one that the user provided via as
property, requires a size
property? TypeScript reports the usage above as invalid.
While some problems mentioned may be solvable, they are often not handled by libraries and type-checking would get even more complex.
asChild approach
Radix UI, a headless component library, implements an alternative approach. Their components accept an asChild
property that defines whether you want to render a custom element or not.
// Renders the default element, a button
<ToggleButton>Toggle Me</ToggleButton>
// Renders MyButton
<ToggleButton asChild>
<MyButton>Toggle Me</MyButton>
</ToggleButton>
If asChild
is passed, the ToggleButton
component will itself not render a button element but instead forwarding all props (like aria-pressed
) to the child element and merging all existing props on that element.
The type-checking part for this API is much simpler and also solves some of the problems mentioned before. Conflicting props are not a problem anymore, as long as they are not forwarded. You can define the same property on the parent and the child component:
<Button size="md" asChild>
<Stack size="2">...</Stack>
</Button>
Chaining components work too:
<Stack asChild>
<List asChild>
<dl>...</dl>
</List>
</Stack>
If you want to use this approach in your own components, Radix provides the @radix-ui/react-slot package.
Unsolved problems
The asChild
approach doesn't solve all problems. Most importantly, the parent component forwards an unknown list of properties to the child. You as a developer have to ensure that your child component actually supports all of them.
<ToggleButton asChild>
<MyButton>Toggle Me</MyButton>
</ToggleButton>
The ToggleButton
component may forward a few properties like onClick
, aria-pressed
and some data-*
attributes for styling. The MyButton
component must accept all of these properties. If it doesn't, it will just not work without any errors.
Note
While components can define what kind of children
they support, like a string or a function, it's impossible to restrict the children to a certain kind of JSX elements, like only allowing <Button />
elements. This is not yet supported by TypeScript (#21699).
For example, if the MyButton
component is implemented with the useButton
hook of React Aria (a library of React Hooks that provides accessible UI primitives), the expected prop is called onPress
instead of onClick
.
So the general rule would be to use asChild
only with HTML elements or components with an identical API. But there are no automatic checks that ensure the correct usage.
There was an idea for a render prop API on GitHub that would partially solve this issue by passing a typed props
object:
<ToggleButton asChild>
{(
props,
ref // Props is type-safe
) => <Button {...props} onPress={props.onClick} ref={ref} />}
</ToggleButton>
This approach allows you to change how the props are forwarded, e.g. to forward the onClick
handler as onPress
. But because of the way TypeScript works it doesn't prevent you from passing invalid properties - there's no error if you don't rename onClick
even if it's not an allowed property. If ToggleButton
ever adds a new property that isn't supported by Button
, it's just forwarded as it is and may not work as expected.
Summary
Polymorphic components are hard - and error-prone. The asChild
approach is not perfect either but maybe the less bad option. I think we should adopt this pattern in favor of the as
prop until someone comes up with a better alternative.
In the meantime, upvote and subscribe to the related TypeScript issue and start adopting the asChild
prop in your codebase.