Skip to main content

Command Palette

Search for a command to run...

Polymorphic components in React

But it's not what you think.

Updated
5 min read
Polymorphic components in React
M

I build software | Startupper / Blogger / Activist / Published Author / Speaker / He, him

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.

G

Buy High Quality Counterfeit Money/Buy cloned cards online/Buy PayPal Top-up/WhatsApp…..+49 1521 9324117/Telegram: @matrixccc https://t.me/thematrixshops/https://thematrixcartel.store/

Fake Euro notes and Cloned credit cards available for sale. We also do PayPal transfer Topups. Tap in for more information on our services. Telegram username: @matrixccc https://t.me/thematrixshops https://thematrixcartel.store/ Whatsapp +49 1521 9324117 Ψεύτικα χαρτονομίσματα ευρώ και κλωνοποιημένες κάρτες προς πώληση Ψεύτικα χαρτονομίσματα ευρώ και κλωνοποιημένες πιστωτικές κάρτες προς πώληση. Κάνουμε επίσης PayPal μεταφορά Topups. Πατήστε για περισσότερες πληροφορίες σχετικά με τις υπηρεσίες μας. Όνομα χρήστη Telegram: @matrixccc https://t.me/thematrixshops Κανάλι Whatsapp +49 1521 9324117

https://thematrixcartel.store/

Buy counterfeit money Grade AAA Quality Counterfeit Money For Sale Buy Greece (EUR €) The Matrix Cartel https://thematrixcartel.store/

WhatsApp…..+49 1521 9324117

M

Can this pattern be combined with StyledComponents? If so, how to do it right?

M

No clue. I don't use styled components and I don't recommend using them to anyone.

M

Michał Miszczyszyn

Ok, then I am missing a key piece of information in the presented pattern - how to pass information to < UIButton /> about what state the placed children are in, especially when our UI is dependent on the state of the application (spinner animation in the UIButton when it processes a request, or disabled state) e.g:

< UIButton > < button > Send Request < / button > < / UIButton >.

< Link >Try to redirect < / Link > < / UIButton >.

2
M

Mariusz Kowalski that's a good point; the UI components could accept props that influence how they look, i.e. variant or disabled.