Skip to content

Polymorphic React Components are quite tricky

Polymorphic React components are very common and useful, but also tricky to implement and limited in what type-safety they provide. We'll talk about the problems and see an alternative approach using an asChild prop.

Published
Apr 02, 2023
Updated
Apr 02, 2023
Reading time
6 min read

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.