Function asChild

The pitfalls of polymorphic components, and how I use a time-tested React design pattern to overcome them
As a design system engineer, my primary goal is to deliver a consistent user experience across a range of products. Succeeding in this requires consistent reuse of the components I build. Consistent reuse of these components requires them to adapt to a wide range of use cases. Otherwise, my clients will opt out and roll their own components, typically losing important functionality or design details in the process.
My most essential technical tool for delivering the required flexibility is the notion of the polymorphic component. Before jumping into code, allow me to explain this important concept and why it's so useful.
What are polymorphic components?
A polymorphic component delivers consistent visual design and functionality in a context-sensitive manner as follows:
-
It offers direct control over the rendered markup, allowing developers to improve accessibility via semantic HTML.
-
Its appearance and/or behavior can be combined with those of another component.
A Button component is the canonical example of component polymorphism: By
default, it just renders a HTML <button> element with some fancy styling, but
it can be configured to render an <a> element instead (like when a screen
reader should announce it as a link).
Aside from custom HTML tags, a Button might be combined with e.g. a Link
component from a routing framework like
React Router or
Next.js—bringing
the visual design of Button and the functionality of Link together into a
single element.
In short, the flexible nature of polymorphic components allows a single implementation to serve a wider range of use cases. This promotes reuse of UI elements, improving code maintainability while delivering a more cohesive user experience.
Current approaches
Over the past few years, two approaches for achieving polymorphic components
have been widely circulated and discussed within the React community: the as
prop and the asChild prop. Before reinventing the wheel, let's take a look at
these.
The as prop
The as prop gained prominence around 2018, when
Material UI exposed the pattern
through its component prop and
Styled Components
introduced as as a first-class API.
Chakra UI soon adopted the
same convention. Using the as prop, the preceding button-as-link use case
would be implemented something like this:
<Button as="a" href="https://react.dev">
Get started
</Button>
For TypeScript users, the as prop can be especially interesting because a
typical type definition will guard against invalid prop combinations depending
on the value of the as prop. For example, this would not compile since href
is not a valid HTML <button> attribute:
<Button as="button" href="https://react.dev">
Get started
</Button>
A couple of years ago, the as prop was practically synonymous with the term
polymorphic component, and for good reason. It is widely recognized; it's easy
to use; and it offers a reasonable degree of type safety. Like any abstraction,
it also makes tradeoffs.
Tradeoffs of the as prop
Typed implementations of the as prop have attracted criticism for expensive
type checking and brittle inference. Both concerns depend heavily on the
implementation, and
TypeScript 7's native compiler
substantially reduces checking time while preserving TypeScript 6's inference
semantics. Personally, I haven't noticed either issue much. For me, the critical
issue is composability. Composition is limited to two components sharing a
single prop namespace.
Prop collisions
Consider two polymorphic components: Shadow, which adds a configurable drop
shadow to an element, and Border, which similarly adds a colored border around
an element.
<>
<Shadow color="blue">Shadow</Shadow>
<Border color="blue">Border</Border>
</>
You can combine the two using the as prop.
<Shadow as={Border} color="blue">
Shadow + Border
</Shadow>
But what if you want a two-tone effect?
<Shadow as={Border} color="pink" color="blue">
Shadow + Border
</Shadow>
Unfortunately, it isn't possible to achieve this on the basis of the Shadow
and Border components because you can't control each component's color prop
independently. The duplicate color prop in the code example simply wouldn't
compile.
Composition limits
Now let's return for a moment to the monochromatic combination that actually works.
By default, this renders a generic <div> element. But what if you need to
render a <button> element instead?
<Shadow as={Border} as="button" color="blue">
Shadow + Border
</Shadow>
Once again, the duplicate as prop wouldn't compile, making it impossible to
combine the two components and control their rendered HTML at the same time.
Shared prop namespace
In short, this design presents two challenges:
-
If two components share a prop name, that prop can't be controlled independently for each component. (Technically, one of the props hides the other's interface—imagine if they have different types!)
-
Composition is limited to two components—unless you need a custom HTML tag, in which case composition isn't possible because the
asprop is already used for that purpose.
The asChild prop
The asChild prop, popularized by
Radix, addresses
the issues of the as prop by allowing the component's child element to
determine how it is rendered. Using asChild, here is how the button-as-link
use case I introduced earlier would be implemented:
<Button asChild>
<a href="https://react.dev">Get started</a>
</Button>
To the compiler, this looks like a plain HTML <a> element passed as the child
of <Button>. TypeScript can check the anchor element's props directly instead
of resolving them through a polymorphic generic.
Because <Button> and <a> are separate elements, a given prop can be set
explicitly on either one, avoiding prop collisions.
That solves real problems with the as prop.
Problems with the asChild prop
Unfortunately, asChild's implicit mutation of children introduces its own set
of problems.
Weak contract
A component implementing the asChild prop accepts an element as its child and
forwards props and a ref to it. The child must handle whatever it receives,
whether compatible or not. TypeScript checks the two JSX elements independently,
so it cannot verify compatibility between the props the parent forwards and
those the child accepts.
Here, the custom Link accepts only href and children, silently dropping
the style forwarded by Button:
import type { ComponentProps } from "react";
type LinkProps = Pick<ComponentProps<"a">, "children" | "href">;
function Link({ children, href }: LinkProps) {
return <a href={href}>{children}</a>;
}
<>
<Button asChild>
<a href="/docs">Native anchor works</a>
</Button>
<Button asChild>
<Link href="/docs">Custom Link is broken</Link>
</Button>
</>
Invalid HTML
asChild's prop forwarding scheme can easily produce invalid markup. Look no
further than the button-as-link use case for evidence. Consider what happens
when you disable the button.
<Button asChild disabled>
<a href="https://react.dev">Get started</a>
</Button>
The resulting HTML would look essentially like the following—with no TypeScript
error to alert you that the disabled prop
is not compatible
with an <a> element.
<a href="https://react.dev" disabled>Get started</a>
Opaque child
A Button component implemented with an as prop can render the correct props
depending on the element type. For example, it might render a button element
like this:
<button disabled>Get started</button>
In that case, the corresponding anchor element output should include
aria-disabled and onClick props instead of disabled:
<a
href="https://react.dev"
aria-disabled="true"
onClick={event => {
event.preventDefault();
}}>
Get started
</a>
An asChild implementation cannot make this choice reliably because it does not
know what kind of child it renders.
Introducing Function asChild
After realizing the tradeoffs of the as and asChild props, I wanted to find
a sounder approach to polymorphic components. Rather than inventing something
new, I dusted off a
render prop variant known
as
Function as Child Component,
used by
React Motion
as early as 2015. With a few small tweaks, it resembles the asChild pattern,
but provides a safer interface between parent and child.
Quick preview
Let's return to the polymorphic Button to see what Function asChild looks
like from the consumer's perspective.
By default, the Button component provides the same interface as the HTML
button tag, enhanced with any additional props, e.g. a variant.
<Button type="submit" form="loginForm" variant="primary">
Submit
</Button>
When you want to render a different type of element, you pass a render function as child.
<Button variant="primary">
{({ disabled, style }) => (
<a
href="https://react.dev"
aria-disabled={disabled}
onClick={event => disabled && event.preventDefault()}
style={style}>
Get started
</a>
)}
</Button>
The JSX resembles the asChild version, except the child is a render function
rather than an element. Its parameters make the forwarded props visible to
TypeScript and give the consumer control over how they map to rendered HTML.
If you've used render props before, this might look familiar, but it's important to get the implementation and usage details right.
Polymorphic component implementation
The first step is to define the forwarded props interface between the Button
component and the render function. This Button exposes its resolved style
along with disabled, whose meaning must be adapted when the consumer renders
something other than a button.
import type { CSSProperties } from "react";
export type ButtonForwardedProps = {
disabled: boolean;
style: CSSProperties;
};
Next, define Button's own props. ButtonSharedProps represents the
component's core API: the props that apply regardless of rendering mode, i.e.
whether the Button component renders a native <button> or calls the
children render prop. It does not include children or the full set of native
button props. Those depend on the rendering mode.
import type { CSSProperties } from "react";
export type ButtonSharedProps = {
disabled?: boolean;
style?: CSSProperties;
variant?: "primary" | "secondary";
};
The ButtonProps type extends ButtonSharedProps with a union of interfaces
corresponding to each rendering mode.
import type { ComponentProps, ReactElement } from "react";
export type ButtonProps = ButtonSharedProps &
(
| Omit<ComponentProps<"button">, keyof ButtonSharedProps>
| {
children: (forwardedProps: ButtonForwardedProps) => ReactElement;
}
);
Finally, the implementation determines rendering mode by checking the type of
children.
- If
childrenis a function, call it withforwardedPropsand return the result. - Otherwise, render a native
<button>withrestProps,forwardedProps, andchildren.
export function Button({
children,
disabled = false,
style,
variant = "secondary",
...restProps
}: ButtonProps) {
const forwardedProps: ButtonForwardedProps = {
disabled,
style: {
background: variant === "primary" ? "royalblue" : "slategray",
color: "white",
...style,
...(disabled ? { opacity: 0.5 } : undefined),
},
};
return typeof children === "function" ? (
children(forwardedProps)
) : (
<button {...restProps} {...forwardedProps}>
{children}
</button>
);
}
That completes the implementation. But TypeScript cannot catch every prop-forwarding mistake, so the call site needs a few additional guardrails.
Call-site guardrails
The obvious usage pattern is to spread the forwarded props onto the child:
<Button variant="primary">
{props => (
<a href="https://react.dev" {...props}>
Get started
</a>
)}
</Button>
This compiles even though props includes disabled, which is not valid on an
anchor. Due to a
long-standing issue,
TypeScript does not report excess props introduced through an object spread.
Explicit prop forwarding
Avoid spreading props onto the target JSX element. Instead, forward each prop individually so the compiler can flag incompatibilities.
<Button variant="primary">
{({ disabled, style }) => (
<a
href="https://react.dev"
aria-disabled={disabled}
onClick={event => {
if (disabled) event.preventDefault();
}}
style={style}>
Get started
</a>
)}
</Button>
Exhaustiveness check
When forwarding props explicitly, it's easy to miss one during
destructuring—especially when a new prop is later added to
ButtonForwardedProps. Use the satisfies operator to assert that restProps
has been exhausted.
<Button variant="primary">
{({ disabled, style, ...restProps }) => {
restProps satisfies Record<PropertyKey, never>;
return (
<a
href="https://react.dev"
aria-disabled={disabled}
onClick={event => {
if (disabled) event.preventDefault();
}}
style={style}>
Get started
</a>
);
}}
</Button>
For example, here's what happens if you forget about the style prop:
<Button variant="primary">
{({ disabled, ...restProps }) => {
restProps satisfies Record<PropertyKey, never>; return (
<a
href="https://react.dev"
aria-disabled={disabled}
onClick={event => {
if (disabled) event.preventDefault();
}}>
Get started
</a>
);
}}
</Button>
Unused prop check
All forwarded props should generally be consumed in some way. Otherwise, an
unused prop may represent missing appearance or behavior. While the satisfies
assertion ensures that all forwarded props are destructured, it does not
guarantee that each one is actually used. For this purpose, I recommend the
ESLint rule
@typescript-eslint/no-unused-vars.
With this rule enabled, here's what happens if you forget to forward the
disabled prop:
<Button variant="primary">
{({ disabled, style, ...restProps }) => { restProps satisfies Record<PropertyKey, never>;
return (
<a href="https://react.dev" style={style}>
Get started
</a>
);
}}
</Button>
Composition demo
Now let's return to the Shadow and Border components that demonstrated the
composition limits of the as prop.
To implement them, we'll use a helper component that applies a style using the Function asChild pattern.
import type { CSSProperties, ReactNode } from "react";
type StyledProps = {
children: ReactNode | ((props: { style: CSSProperties }) => ReactNode);
style: CSSProperties;
};
function Styled({ children, style }: StyledProps) {
return typeof children === "function" ? (
children({ style })
) : (
<span style={style}>{children}</span>
);
}
The Shadow, Border, and Text components each use Styled to contribute a
style object.
function Shadow({ children }: Pick<StyledProps, "children">) {
return <Styled style={{ boxShadow: "8px 8px hotpink" }}>{children}</Styled>;
}
function Border({ children }: Pick<StyledProps, "children">) {
return <Styled style={{ border: "2px solid royalblue" }}>{children}</Styled>;
}
function Text({ children }: Pick<StyledProps, "children">) {
return <Styled style={{ fontSize: 20, fontWeight: 600 }}>{children}</Styled>;
}
Nested render functions then compose the forwarded props transparently at the call site.
<Shadow>
{({ style: shadow, ...shadowRestProps }) => {
shadowRestProps satisfies Record<PropertyKey, never>;
return (
<Border>
{({ style: border, ...borderRestProps }) => {
borderRestProps satisfies Record<PropertyKey, never>;
return (
<Text>
{({ style: text, ...textRestProps }) => {
textRestProps satisfies Record<PropertyKey, never>;
return (
<button type="button" style={{ ...shadow, ...border, ...text }}>
Composed button
</button>
);
}}
</Text>
);
}}
</Border>
);
}}
</Shadow>
Is the verbosity worth it?
Admittedly, this is verbose. Function asChild moves work that other polymorphic APIs hide into the call site. In return, it provides:
-
Local inference for forwarded props
-
No shared prop namespace between composed components
-
Explicit prop forwarding and translation
-
Direct control over the rendered HTML
-
Ability to compose three or more components
At the same time, not every call site needs a render function. Optimize for the default rendering mode and use a render function only when composition or accessibility requirements demand it.
Polymorphism can also remain an implementation detail. Monomorphic components can be built on top of polymorphic primitives, exposing simple APIs for common cases while using explicit composition internally. Thus each layer hides complexity from consumers without compromising safety.
Finally, weigh the benefits of polymorphism against its costs. Sometimes DRY can be taken too far.
Stay informed
Subscribe to email updates and be the first to know when I post new content.
I hate spam as much as you do.
Unsubscribe at any time — no hard feelings!