Polymorphic components in React

Polymorphic components in React

But it's not what you think.

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.

Did you find this article valuable?

Support Michał Miszczyszyn by becoming a sponsor. Any amount is appreciated!