Polymorphic Components in React with TypeScript
React Polymorphic components are a powerful pattern in React development, enabling us to create flexible and reusable components. These components can adapt to different HTML elements or custom components while retaining their own functionalities and props. This flexibility is achieved using the as prop, a concept integral to polymorphic components.
1. Basic Polymorphic Component
The "as" prop in polymorphic components allows the component to change the type of the rendered element dynamically. This means you can have a single component that can act as a div, span, header, or any custom component, depending on your needs. This approach is incredibly useful for building a design system or consistent UI with a shared set of styling and behavior rules, without repeating code.
Let's start by creating a simple polymorphic component named Head. This component will accept a prop as, which determines the type of element or component it renders. Additionally, it will take children, the standard prop for passing elements to be rendered inside the component.
import { type ReactNode, type ElementType } from "react"
type HeadProps = {
as: ElementType
children: ReactNode
}
const Head = ({ as: Component, children }: HeadProps) => {
return <Component>{children}</Component>
}
export default Head
Usage example:
import './App.css'
import Head from './Head'
function App() {
return (
<>
<Head as='h1'>H1 header</Head>
<Head as='h2'>H2 header</Head>
</>
)
}
export default App
This is exactly what we see, two headings, h1 and h2:
2. Advanced Polymorphic Component
A common challenge arises when we want to add more functionality, like event handlers. Let's delve into how we can enhance our polymorphic components to handle such cases. Suppose we want to add an onClick event listener to our component. The current implementation of HeadProps only accepts as and children props, limiting our component's flexibility. To address this, we need to accept all props of the concrete component specified by the as prop. In order To incorporate additional props, we'll use the utility type ComponentPropsWithoutRef. This type can be used to grab all the native attributes of an HTML element as the props type of our component. The "problem" now is that we don't know the type of the component that will be passed in, and the solution for this is to create a new generic type of out own.
type HeadProps<T> = {
as: T
children: ReactNode
} & ComponentPropsWithoutRef<T>
Now, ComponentPropsWithoutRef will extract the props of the incoming T component. The type of "as" is simply T because it represents the element itself. However, technically, T could currently be anything, such as a number or a string, which wouldn't be valid as an element. To address this issue, TypeScript allows us to extend the base type that T is based upon.
type HeadProps<T extends ElementType> = {
as: T
children: ReactNode
} & ComponentPropsWithoutRef<T>
Now, as can be seen in the screen shot, Typescript complains that HeadProps is a generic type and requires 1 type argument(s)
领英推荐
To fix this, we need to adjust the type of the Head component accordingly. This means it also must be a generic type, which we'll mark as C. Since C could potentially be anything, again, we need to constrain it as well. We'll do this by ensuring that it extends only ElementType to guarantee that it can only be a component and not a string, for example.
At this point Typescript is still not happy and complains about "Type '{ children: ReactNode; }' is not assignable to type 'LibraryManagedAttributes<C, any>'.ts(2322)"
A common pattern to resolve this issue involves defining the "as" prop as a Component or 'div' as the default component (see "comment"), and marking the "as" prop as optional. This way, TypeScript understands that Component will always be a valid element that can be used in JSX and serve as a wrapper for the children prop.
import { type ReactNode, type ElementType, type ComponentPropsWithoutRef } from "react"
type HeadProps<T extends ElementType> = {
as?: T // The "comment"
children: ReactNode
} & ComponentPropsWithoutRef<T>
const Head = <C extends ElementType>({ as, children }: HeadProps<C>) => {
const Component = as || 'div' // The "comment"
return <Component>{children}</Component>
}
export default Head
By this time we eliminated all Typescript errors and we can implement the final stage of forwarding the rest of the Props. We need to accept "...props" in our component, allowing any extra props to be passed and handled correctly. This makes our Head component versatile and capable of handling various props, including event listeners.
import { type ReactNode, type ElementType, type ComponentPropsWithoutRef } from "react"
type HeadProps<T extends ElementType> = {
as?: T
children: ReactNode
} & ComponentPropsWithoutRef<T>
const Head = <C extends ElementType>({ as, children, ...props }: HeadProps<C>) => {
const Component = as || 'div'
return <Component {...props}>{children}</Component>
}
export default Head
For example, now we can add click event handling to our Head component to enhance its flexability.
How to use:
function App() {
const handleClick = () => {
console.log('clicked')
}
return (
<Head as='h1' onClick={handleClick}>Clickable H1 header</Head>
)
}
export default App
Conclusion
In this article, we've enhanced our polymorphic component using TypeScript to be more flexible and capable of handling various props, including event listeners. This approach significantly boosts the reusability and adaptability of our components, making them an invaluable tool in a React developer's toolkit.
Ciao
Senior frontend developer | React, Typescript | Open to Relocation
9 个月Nice but I want to refer to this https://blog.logrocket.com/build-strongly-typed-polymorphic-components-react-typescript/ article .may be take a short glance of it can reveal more secrets from this article
Frontend developer React | Next | Node | Typescript | Tailwindcss
11 个月I actually feel like a senior dev after understanding this article ?? LOL, Thank you man!