Kristóf Poduszló

Behind the ‘as’ prop: polymorphism done well

4 min read Updated

A button renderable as a link has quirky types in React. How could custom tags be assigned to such components?

Reusable bits of a site mostly have rigid HTML semantics. Some of those constraints are unwarranted, though.

Starting with a simple button, we may end up using it for navigation:

import { clsx } from "clsx/lite"; // Joins CSS class names
interface MyButtonProps
extends React.ComponentPropsWithoutRef<"button"> {
className?: string; // Redundant but kept for explicitness
}
function MyButton({ className, ...props }: MyButtonProps) {
return (
<button
className={clsx(className, "my-button")}
{...props}
/>
);
}
function App() {
return (
<MyButton onClick={() => navigation.navigate("/")}>
Home
</MyButton>
);
}

Despite acting okay, this pattern has major flaws. Above all, we broke the first rule of ARIA:

If you can use a native HTML element or attribute with the semantics and behavior you require already built in, instead of re-purposing an element and adding an ARIA role, state or property to make it accessible, then do so.

Abiding that, a link will suffice our needs:

<a href="/" className="my-button">
Home
</a>

Styling may be borrowed from within MyButton, just as embraced by Class Variance Authority. However…

CSS has its limits

Many accessible states can only be exposed via HTML. For instance, a toggle button should utilize the aria-pressed attribute, unless its label changes when clicked.

No wonder component-oriented separation of concerns feels so natural.

Encapsulation of behavior

Our goal is to make MyButton render an <a> element when desired, falling back to <button> in the absence of an as prop.

In JSX, lowercase tags like <a> refer to built-in HTML/SVG elements. Conversely, components have their first letter capitalized:

<MyButton onClick={() => navigation.reload()}>Reload</MyButton>

This gets transpiled into a function call behind the scenes:

import { jsx } from "react/jsx-runtime";
jsx(
MyButton, // type
{ onClick: () => navigation.reload() }, // props
"Reload", // children (optional)
);

We may pass variables along to that, e.g. for the type:

let as = MyButton;
jsx(as, {}); // Renders `<button>` ✅

Yet we can’t follow suit in JSX because as is lowercase, implying a built-in element:

let as = MyButton;
<as />; // Renders `<as>` ❌ — via `jsx("as", {})`

How might we tell JSX otherwise? Just use a PascalCase variable:

let as = MyButton;
const Element = as;
<Element />; // Renders `<button>` ✅

That idea forms the basis for our polymorphic component:

import { clsx } from "clsx/lite";
function MyButton({ as = "button", className, ...props }) {
const Element = as;
return (
<Element
className={clsx(className, "my-button")}
{...props}
/>
);
}

So far so good. The biggest hurdle is yet to come, though.

Adding types

Let’s begin from the inside out:

import { clsx } from "clsx/lite";
function MyButton({ as = "button", className, ...props }) {
const Element: React.ElementType = as;
return (
<Element
className={clsx(className, "my-button")}
{...props}
/>
);
}

It’s tempting to use React.ElementType<P = any> as is. However, that goes over all the built-in elements, keeping the ones taking P as attributes.

Luckily, we can narrow the set of tags endorsed, reducing type instantiations by an order of magnitude:

type MyButtonElementType = React.ElementType<
any,
"button" | "a" // Other tags could be wrapped by components
>;

(A tag-independent abstraction should likely be refactored into a hook at this point.)

At last, it’s time to declare the props of MyButton:

import { clsx } from "clsx/lite";
type Merge<T, U> = DistributiveOmit<T, keyof U> & U;
type DistributiveOmit<
T,
K extends PropertyKey,
> = T extends unknown ? Omit<T, K> : never;
type MyButtonElementType = React.ElementType<
any,
"button" | "a"
>;
type MyButtonProps<T extends MyButtonElementType = "button"> =
Merge<
React.ComponentPropsWithoutRef<T> & { as?: never },
{
as?: T;
className?: string;
}
>;
function MyButton<T extends MyButtonElementType = "button">({
as = "button" as T,
className,
...props
}: MyButtonProps<T>) {
const Element: MyButtonElementType = as;
return (
<Element
className={clsx(className, "my-button")}
{...props}
/>
);
}

This concludes our journey towards creating a type-safe polymorphic component in React.

While type assertions are bad practice in TypeScript, we may either:

  • Make the as prop required, undermining convenience.
  • Define const Element = as ?? "button" and remove the assignment from as = "button". This results in bogus auto-completion for Element props.
  • Assume the default value "button" is assignable to T, even though setting the type argument explicitly like <MyButton<"a">> voids that.

I’ve opted for the latter but ultimately, it’s a matter of personal choice. Life is never black and white, hence being full of beauty 🌈