Every now and then, I encounter a situation where I need a link that looks like a button. Or a button that looks like a link. Isn't it a perfect use case for a polymorphic component? In this article, I'll argue why polymorphic components should be avoided. I'll also present a pattern that can be used instead.
What are polymorphic components?
You've probably heard of them. Seen them. Used them. Maybe even tried building one. Simplifying, polymorphic components render as different elements based on props passed to them. A typical example is LinkButton
that takes an as
prop and can either be a button
or a
:
<LinkButton
as="a"
href="/"
/>
<LinkButton
as="button"
onClick={(e) => {
console.log(e);
}}
/>
Mind how the as
prop influences other props as well. In this case, when passing as="a"
, you'll also need to add href
. And when passing as="button"
, href
will be forbidden. Looks nice.
Polymorphic components in TypeScript
Implementing polymorphic components in TypeScript requires a wee bit of trickery. You can read all about it in this excellent piece by Matt Pocock: Polymorphic Link/Button Components in React & TypeScript. Doesn't look so bad! But…
Accidental and true duplication
In "Clean Architecture" by Robert C. Martin, the author mentions a concept that deeply resonated with me. There are two kinds of duplications: true (bad) duplication and false (accidental) duplication.
True duplication is when the same code or logic is repeated in multiple places.
Accidental duplication is when the same code or logic is repeated in multiple places.
Just by looking at the code in situ, it's impossible to say which kind of duplication we're dealing with. That's why it's so tricky. Only when the codebase starts evolving will we see that accidentally similar pieces of code are changing in a different manner and for various reasons. In contrast, truly duplicated code always changes in the exact same way at the same time. That's why it's so important not to avoid duplication at all costs. I suggest waiting and observing which pieces of code will benefit from deduplicating.
What does it have to do with anything? My experience tells me that polymorphic components are mostly used to deduplicate accidentally similar pieces of code.
Diverging polymorphic components
Consider the LinkButton
example above. What happens when we need to add disabled
state support for both a
and button
? The underlying implementations will differ significantly. In the case of a button, it'll be as simple as adding a disabled
attribute to the HTML element. However, for a
to be "disabled", we'll need to add an onClick
handler whose sole responsibility is to preventDefault
. Moreover, the aria-disabled
attribute should be added as well. That forces us to write dirty conditional code. Not only will it cause trouble with maintenance in the future, but it'll be instantly annoying when it comes to getting the types right. Try it for yourself!
Even worse, we're mixing responsibilities! There's no clear separation between the UI and logic. We can't simply reuse LinkButton
's looks for other elements without modifying it.
What happens when we use the same pattern for more complex cases if it's a pain in such a simple scenario? Think of <Container as=…>
or <Card as=…>
– commonly found in different codebases. Yikes.
Enter asChild
The asChild
pattern solves all the mentioned problems. It clearly separates responsibilities. Components built with asChild
in mind are easily reusable for their looks regardless of the component's role. Let's see it in action:
<UIButton asChild>
<a href="/">Go back home</a>
</UIButton>
<UIButton asChild>
<button onClick={…}>Do something</button>
</UIButton>
Smooth, right?
The UIButton
component is completely agnostic of the element inside – an a
, button
, div
or any other. Moreover, no additional HTML tags are rendered.
asChild
and Slot
Implemented
Often, the asChild
is optional. When you omit it, a default element is rendered instead of relying on the child. See Radix, for example. However, it stems from my experience that it's a good practice to make the child element required. I never render the default element and always expect the developers to use children
. In this case, the asChild
prop is not really required. It's just a pattern. A clear way of indicating that a given component needs to be used in a certain way and doesn't render anything on its own.
Either way, the true magic happens inside the Slot
component:
import clsx from "clsx";
import { isValidElement, cloneElement, Children, type HTMLAttributes, type ReactElement } from "react";
export const Slot = ({
children,
...props
}: HTMLAttributes<HTMLElement> & {
children: ReactElement;
}) => {
if (isValidElement<HTMLAttributes<HTMLElement>>(children)) {
return cloneElement(children, {
...props,
...children.props,
className: clsx(children.props.className, props.className),
});
}
throw new TypeError(`Single element child is required in Slot`);
};
What's going on here? Let's go through the code step by step. We declare a Slot
component that takes all props that any element could take. Additionally, we require children
of type ReactElement
– meaning that exactly one element needs to be passed to it.
Then, we do a runtime validation of whether the provided children
is, in fact, a valid React element. If it is, we clone that element, merge props passed to Slot and the element, and return it. We also take extra care of the className
using the clsx
library. You might want to use tailwind-merge
instead if that's your jam.
Then, the Slot
is reused in different UI…
components in the following manner:
import { clsx } from "clsx";
import { type ReactElement, type ButtonHTMLAttributes } from "react";
import { Slot } from "./Slot";
interface UIButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
asChild: true;
children: ReactElement;
}
export const UIButton = ({
asChild: _,
...props
}: UIButtonProps) => {
return (
<Slot
{...props}
className={clsx(
"your classes to make it look nice",
props.className,
)}
/>
);
};
We ignore the asChild
and pass all remaining props to Slot
. We also add classes responsible for how the element is supposed to look and merge them with the parent class names. And this is it!
Summary
No conditional logic, straightforward separation of concerns, easy maintenance. There's now only a single reason to modify the UI…
components: when we need to change how they look.
The added benefit is how simple it has become to build a Storybook with such components. No need to mock any logic or providers. Just use the UI…
components with bare-bone elements, and you're done!
I can't imagine building interfaces without the asChild
pattern anymore. Do you like it? Let me know in the comments.