Type-safe usage of React as a prop for flexible components
Published: Jan 3, 2024
Last updated: Jan 3, 2024
Introduction
Design systems like Chakra UI, Gestalt, Material UI (as component
, not as
) and React Bootstrap provide ways to override the React component in question with a prop.
For example, image an API like the the following that will render a span
instead of a button
.
<Button as="span">I am a span</Button>
But how does this magic work? There are some articles on popular blog posts such as robinwieruch and Developer Way, but I think most miss the mark on how to implement this in a type-safe way.
Let's dive deeper into how we can implement this ourselves (with TypeScript).
Prerequisites
- Working knowledge of React.
- Working knowledge of TypeScript.
Setting up the Vite project
We will use Vite to spin up a quick project to work with.
In the terminal, run the following:
pnpm create vite@latest blog-react-ts-as-prop --template react-ts cd blog-react-ts-as-prop pnpm install pnpm dev
At this point, you're app should be up and running (mine was at http://localhost:5173/), so we are ready to move forward!
Understanding React.ElementType
As of writing, the definition for this type comes from here.
type ElementType< P = any, Tag extends keyof JSX.IntrinsicElements = keyof JSX.IntrinsicElements, > = | { [K in Tag]: P extends JSX.IntrinsicElements[K] ? K : never }[Tag] | ComponentType<P>;
The tl;dr on this section is that ElementType
is a type that can be either a ComponentType
or a keyof JSX.IntrinsicElements
where the keyof JSX.InstrinsicElements
can be any valid HTML attribute key e.g. p
or button
.
This is the magic ingredient for an as
prop, so let's implement that now.
Implementing the as
prop
Within src/App.tsx
, let's create a Button
component that will take an as
prop near the top of the file.
function Button({ as, children, }: { as: React.ElementType; children: React.ReactNode; }) { return React.createElement(as, null, children); }
Based off what we know, we should be able to pass in any valid HTML attribute key or React component and have it render as that element.
Let's test this out by rendering a button
, a p
element and a random component. The entire file will end up looking like this:
import React from "react"; import "./App.css"; function Button({ as, children, }: { as: React.ElementType; children: React.ReactNode; }) { return React.createElement(as, null, children); } function ValidDivComponent({ children }: { children: React.ReactNode }) { return <div>{children}</div>; } function App() { return ( <> <Button as="p">p tag</Button> <Button as="button">actual button</Button> <Button as="a">anchor tag</Button> <Button as={ValidDivComponent}>div from component</Button> </> ); } export default App;
If you were to open the browser console and check the HTML, you will see the following HTML from the #root
div:
<div id="root"> <p>p tag</p> <button>actual button</button> <a>anchor tag</a> <div>div from component</div> </div>
Great! We have a working as
prop. But what about TypeScript? Right now, we can tell our button what component to render, but we cannot actually infer the props so that TypeScript can help at the call site.
Making our types a little more flexible
At this point, we need to dive into generics to help us out.
Side note: I have another post that also works through generics more in-depth.
Our problem is this: we want to be able to pass in a component and have TypeScript infer the props for us.
We can solve this problem by abstracting the button type, introducing React.ComponentPropsWithoutRef
and making it generic.
Let's update our Button
component to the following:
type ButtonProps<T extends React.ElementType> = T extends React.ElementType ? { as: T } & React.ComponentPropsWithoutRef<T> : never; function Button<T extends React.ElementType>({ as, ...props }: ButtonProps<T>) { const Component = as; return <Component {...props} />; }
In the above, we have introduced a generic T
that extends React.ElementType
. We then use this generic to create a ButtonProps
type that will take in a T
and return a type that is either the as
prop or never
.
We then use React.ComponentPropsWithoutRef
to infer the props for the T
type and then use the as
prop to render the component.
Let's test this out by creating a ValidDivComponent
that takes in a name
prop and then render it with our Button
component.
function ValidDivComponent({ name }: { name: string }) { return <div>{name}</div>; }
Update the entire file code now to the following:
import React from "react"; import "./App.css"; type ButtonProps<T extends React.ElementType> = T extends React.ElementType ? { as: T } & React.ComponentPropsWithoutRef<T> : never; function Button<T extends React.ElementType>({ as, ...props }: ButtonProps<T>) { const Component = as; return <Component {...props} />; } function ValidDivComponent({ name }: { name: string }) { return <div>{name}</div>; } function App() { return ( <> <Button as="p">p tag</Button> <Button as="button" href="#"> actual button </Button> <Button as="a" href="#"> anchor tag </Button> <Button as={ValidDivComponent} /> </> ); } export default App;
Once you save, you'll notice that we now have two TypeScript errors (this is assuming your IDE is set up to use TypeScript).
- Our
<Button as="button" href="/">
renders an error becausehref
is not valid. - Our
<Button as={ValidDivComponent} />
renders an error because we are missing thename
prop.
As an added bonus, you'll notice that our <Button as="a" href="/">anchor tag</Button>
component does not render an error as href
is a valid prop for an a
tag.
Hooray for generics! Now we have a type-safe as
prop that we can use to render any component we want.
If we update the App
function code, we will be back in business with no errors:
function App() { return ( <> <Button as="p">p tag</Button> <Button as="button">actual button</Button> <Button as="a" href="#"> anchor tag </Button> <Button as={ValidDivComponent} name="div component" /> </> ); }
But what happens if we don't want to accept certain intrinsic elements? Let's look at how we can do that next.
Restricting the as
prop
The TypeScript Extract utility enables us to extract common types between a type argument and a union argument.
For example:
type T0 = Extract<"a" | "b" | "c", "a" | "f">;
In the above, T0
will be "a"
as it is the only common type between the two arguments.
We can use this to restrict the as
prop to only accept certain HTML elements.
Let's first create a new LimitedElementType
to allow for anything that is a React component or a button
, a
or span
tag.
type LimitedElementType = Extract< React.ElementType, "button" | "a" | "span" | React.JSXElementConstructor<unknown> >;
With that in place, let's update our Button
code and props:
type ButtonProps<T extends LimitedElementType> = T extends LimitedElementType ? { as: T } & React.ComponentPropsWithoutRef<T> : never; function Button<T extends LimitedElementType>({ as, ...props }: ButtonProps<T>) { const Component = as; return <Component {...props} />; }
At this point, you'll notice our that we have one new error: <Button as="p">p tag</Button>
has the following error:
Type '"p"' is not assignable to type '"a" | "button" | "span" | ComponentClass<any, any> | FunctionComponent<any>'.
This is because we have restricted the as
prop to only accept certain types. Perfect!
The last step is to handle a default option of button
if no as
prop is passed in.
Defaulting to button
In order to add this part, all we need to do is add some default values across the code.
import React from "react"; import "./App.css"; type LimitedElementType = Extract< React.ElementType, "button" | "a" | "span" | React.JSXElementConstructor<unknown> >; type ButtonProps<T extends LimitedElementType = "button"> = T extends LimitedElementType ? { as?: T } & React.ComponentPropsWithoutRef<T> : never; function Button<T extends LimitedElementType = "button">({ as = "button", ...props }: ButtonProps<T>) { const Component = as; return <Component {...props} />; } function ValidDivComponent({ name }: { name: string }) { return <div>{name}</div>; } function App() { return ( <> <Button as="button">actual button</Button> <Button as="button">default button</Button> <Button as="a" href="/"> anchor tag </Button> <Button as={ValidDivComponent} name="Hello" /> </> ); } export default App;
The first step was to add a default value of "button"
to the LimitedElementType
type.
type ButtonProps<T extends LimitedElementType = "button"> = T extends LimitedElementType ? { as?: T } & React.ComponentPropsWithoutRef<T> : never;
Secondly, we added that to our Button
component and also added the default to our as
prop.
function Button<T extends LimitedElementType = "button">({ as = "button", ...props }: ButtonProps<T>) { const Component = as; return <Component {...props} />; }
Now you can run the code and see that our default button
renders as a button
element.
Even better, if you add href
without an as
prop, you will get an error.
Happy days!
Conclusion
In this post, we have looked at how we can implement a type-safe as
prop for our React components.
We have also looked at how we can restrict the as
prop to only accept certain HTML elements.
I hope you have found this post useful and it helps you in your React TypeScript journey!
You can find the code on this TypeScript Playground or
References and Further Reading
Photo credit: boliviainteligente
Type-safe usage of React as a prop for flexible components
Introduction