Behind the ‘as’ prop: polymorphism done well
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
:
- With discriminated unions in mind, we merge the
as
prop using a distributive conditional type sinceOmit<T, K>
only retains properties common to every object in theT
union. - Components rendered by
MyButton
may not require anas
prop because that can’t be passed along from outside.P & { as?: never }
guards against suchP
types.
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 fromas = "button"
. This results in bogus auto-completion forElement
props. - Assume the default value
"button"
is assignable toT
, 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 🌈