🎉 I'm releasing 12 products in 12 months! If you love product, checkout my new blog workingoutloud.dev

Back to home

Type-safe usage of React as a prop for flexible components

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).

  1. Our <Button as="button" href="/"> renders an error because href is not valid.
  2. Our <Button as={ValidDivComponent} /> renders an error because we are missing the name 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

Personal image

Dennis O'Keeffe

@dennisokeeffe92
  • Melbourne, Australia

Hi, I am a professional Software Engineer. Formerly of Culture Amp, UsabilityHub, Present Company and NightGuru.
I am currently working on Visibuild.

1,200+ PEOPLE ALREADY JOINED ❤️️

Get fresh posts + news direct to your inbox.

No spam. We only send you relevant content.

Type-safe usage of React as a prop for flexible components

Introduction

Share this post