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 🌈