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 🌈