Build polymorphic React components with Typescript
Let’s say we want to create a Text
component with React and Typescript. A basic implementation could look like:
import React from 'react'
type TextProps = {
children: React.ReactNode
size?: 'sm' | 'md' | 'lg'
color?: 'default' | 'primary' | 'secondary'
className?: string
}
const Text = ({ children, className, size, color }: TextProps) => {
// hooks to return CSS class base on component props
const classes = useGetClasses('Text', { size, color })
return <p className={clsx(classes, className)}>{children}</p>
}
This component works for simple use-cases but if we care about semantic HTML (use HTML heading tags to display titles or subtitles, <p/>
for paragraph) it doesn’t help. We need to adjust the above implementation by adding a as
(you can name it anything you like) property to determine which tag we want to render.
type TextProps = {
children: React.ReactNode
as?: any // We don't know the type of this property yet
}
const Text = ({ children, as, ...rest }: TextProps) => {
// other code
...
const Component = as || 'p';
return (
<Component {...rest}>{children}</Component>
)
}
// Usage
// display with h1 tag
<Text as='h1'>Title</Text>
// display with p tag
<Text as='p'>Long paragraph</Text>
Our Text
component works as expected but these are some couple of issues:
as
property isany
so we can pass anything we want even invalid tag.- We want to pass more attributes which belong to the HTML tag we provide
<Text as="label" htmlFor="username">
Username
</Text>
To fix the first issue, we need as
only accepts a valid React element type. Fortunately, we can achieve this by using : React.ElementType
. Update the implementation:
import React from 'react';
type TextProps<C extends React.ElementType> = {
children: React.ReactNode;
as?: C;
// other properties
};
const Text = <C extends React.ElementType>({ children, as }: TextProps<C>) => {
// other code
...
const Component = as || 'p';
return <Component>{children}</Component>;
};
Now typescript will complain if you try to pass an invalid html tag
// It's ok
<Text as='p'>Hello world</Text>
// Error!!!! Type '"vincenzo"' is not assignable to type 'ElementType<any> | undefined'
<Text as='vincenzo'>Hello world</Text>
To solve second issue, once again, React provides us a useful type React.ComponentPropsWithoutRef
.
type TextProps<C extends React.ElementType> = {
children: React.ReactNode
as?: C
} & React.ComponentPropsWithoutRef<C>
Essentially, the type of TextProps
is an object type containing children
, as
and all valid component properties that correlates with as
tag.
Now, let’s give the solution a try! We try to add htmlFor
property to a Text
component with as
is p
. That’s wrong, and righty caught by TypeScript with the error: Property 'htmlFor' does not exist on type...
// It's ok
<Text as='label' htmlFor='username'>
Username
</Text>
// Error!!!! Property 'htmlFor' does not exist on type 'IntrinsicAttributes & { children: ReactNode; as?: "p" | undefined; }....
<Text as='p' htmlFor='username'>
Username
</Text>
Our component is looking good right now but we still have an issue. The as
property is optional, if we omit it, the component will render as p
tag (as we expected) but Typescript can’t know about this so it can’t check the valid properties for this component.
// No error !!! But it should be
<Text htmlFor="username">User</Text>
To fix this issue, we need to assign a default type for type parameters in a generic type.
const Text = <C extends React.ElementType = 'p' /* default type */>({
children,
as,
...rest
}: TextProps<C>) => {
// other code
...
const Component = as || 'p';
return <Component {...rest}>{children}</Component>;
};
The previous example we had should now throw an error, that is when you pass htmlFor
to the Text
component without an as
prop. It’s magic. ✨✨
We still can improve our Text
component. As we know, some html tags have some internal properties like color
, if we want to provide our own color
property (or other properties), we just don’t want to mess it up. We need to filter our own properties out of internal properties.
// our custom text properties
type Props {
color?: 'primary' | 'secondary',
size?: 'sm' | 'md' | 'lg'
}
// omit internal properties
Omit<React.ComponentPropsWithoutRef<C>, keyOf Props>
The full implementation looks like
type Props<C extends React.ElementType> = {
children: React.ReactNode;
as?: C;
color?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
// other properties...
};
type TextProps<C extends React.ElementType> = Props<C> &
Omit<React.ComponentPropsWithoutRef<C>, keyof Props<C>>;
const Text = <C extends React.ElementType = 'p'>({
children,
as,
...rest
}: TextProps<C>) => {
// other code
...
const Component = as || 'p';
return <Component {...rest}>{children}</Component>;
};
Our Text
component is strongly typed.
Now, let’s take it one step further. We want our solution works for other components not only Text
. To make our solution reuseable, we can add one more generic type which represent the custom component.
Before going ahead, follow the separation of concerns
principle, we should move as
property out of our custom properties and define its own type.
type AsProp<C extends React.ElementType> = {
as?: C;
};
Our reuseable solution will look like
type AsProp<C extends React.ElementType> = {
as?: C
}
type PropWithAs<C extends React.ElementType, P = {}> = P & AsProp<C>
export type PolymorphicComponentProps<
C extends React.ElementType,
Props = {}, // adding one more generic type
> = PropWithAs<C, Props> & Omit<React.ComponentPropsWithoutRef<C>, keyof PropWithAs<C, Props>>
Now we can go ahead and use PolymorphicComponentProps
on our components as follows:
// Text component
type TextProps = {
children: React.ReactNode;
color?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
// other properties...
};
const Text = <C extends React.ElementType = 'p'>({
children,
as,
...rest
}: PolymorphicComponentProps<C, TextProps>) => {
// other code
...
const Component = as || 'p';
return <Component {...rest}>{children}</Component>;
};
// Usage
<Text as='h1'>Heading</Text>
<Text as='label' htmlFor='username'>
Username
</Text>
// Button component
type ButtonProps = {
children: React.ReactNode;
color?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
// other properties...
};
const Button = <C extends React.ElementType = 'button'>({
children,
as,
...rest
}: PolymorphicComponentProps<C, ButtonProps>) => {
// other code
...
const Component = as || 'button';
return <Component {...rest}>{children}</Component>;
};
// Usage
<Button>Submit</Text>
<Button as='a' href='/login'>Login</Text> // act as a link
Everything works like a charm. ✨✨
How about ref
?
To write a functional component which support Ref forwarding, we can use React.forwardRef
function. The Button
component will be look like
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
const { children, ...rest } = props
return (
<button ref={ref} {...rest}>
{children}
</button>
)
})
To create polymorphic component, we need to replace HTMLButtonElement
with a generic type C
. Unfortunately we can’t do that, forwardRef()
is a function call so we can’t define a generic type. So we came up with defining type for the function inside forwardRef()
.
const Button = React.forwardRef(
<C extends React.ElementType = 'button'>(
props: PolymorphicComponentProps<C, ButtonProps>,
ref: unknown, // we still don't know yet
) => {
const { as, children, ...rest } = props
const Component = as || 'button'
return (
<Component ref={ref} {...rest}>
{children}
</Component>
)
},
)
So what type of theref
object? Remember this guy React.ComponentPropsWithoutRef
? he has a brother named React.ComponentPropsWithRef
which includes all the relevant component props based on the element type, plus the ref object. Let’s define a new helper type, PolymorphicRef
, that returns the type of the ref object for the polymorphic component.
type PolymorphicRef<C extends React.ElementType> = React.ComponentPropsWithRef<C>['ref']
const Button = React.forwardRef(<C extends React.ElementType = 'button'>(props: PolymorphicComponentProps<C, ButtonProps>, ref: PolymorphicRef<C>) => {
const { as, children, ...rest } = props
const Component = as || 'button'
return (
<Component ref={ref} {...rest}>
{children}
</Component>
)
})
Our Button
component still isn’t strongly typed. We need to explicitly defined the type annotation for it. Button
component will be receive ButtonProps
and return a JSX
type ButtonComponent = (props: ButtonProps) => React.ReactElement | null
The final step is to update ButtonProp
to support PolymorphicRef
// new helper type which includes the `ref` property for the polymorphic component
type PolymorphicComponentPropsWithRef<
C extends React.ElementType,
Props = {}
>
= PolymorphicComponentProps<C, Props> & { ref?: PolymorphicRef<C> }
type CustomButtonProps = {
children: React.ReactNode;
color?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
};
type ButtonProps<C extends React.ElementType> = PolymorphicComponentPropsWithRef<
C,
CustomButtonProps
>
type ButtonComponent = <C extends React.ElementType = 'button'>(
props: ButtonProps<C>,
) => React.ReactElement | null
const Button: ButtonComponent = React.forwardRef(
<C extends React.ElementType = 'button'>(
props: ButtonProps<C>,
ref: PolymorphicRef<C>
) => {
const { as, children, ...rest } = props;
const Component = as || 'button';
return (
<Component ref={ref} {...rest}>
{children}
</Component>
);
}
);
// Useage
const buttonRef1 = React.useRef<HTMLButtonElement | null>(null);
const buttonRef2 = React.useRef<HTMLDivElement | null>(null);
// It's ok
<Button ref={buttonRef1}>Button1</Button>
// Error!!!! Type 'MutableRefObject<HTMLDivElement | null>' is not assignable to type 'RefObject<HTMLButtonElement>'...
<Button ref={buttonRef2}>Button2</Button>
Finally, we now have a complete solution.
import React from 'react'
// base types
type AsProp<C extends React.ElementType> = {
as?: C
}
type PropWithAs<C extends React.ElementType, Props = {}> = Props & AsProp<C>
type PolymorphicComponentProps<C extends React.ElementType, Props = {}> = PropWithAs<C, Props> & Omit<React.ComponentPropsWithoutRef<C>, keyof PropWithAs<C, Props>>
type PolymorphicRef<C extends React.ElementType> = React.ComponentPropsWithRef<C>['ref']
type PolymorphicComponentPropsWithRef<C extends React.ElementType, Props = {}> = PolymorphicComponentProps<C, Props> & { ref?: PolymorphicRef<C> }
// Usage
// Text component
type CustomTextProps = {
children: React.ReactNode
color?: 'primary' | 'secondary'
size?: 'sm' | 'md' | 'lg'
}
type TextProps<C extends React.ElementType> = PolymorphicComponentProps<C, CustomTextProps>
const Text = <C extends React.ElementType = 'p'>({ children, as, ...rest }: TextProps<C>) => {
const Component = as || 'p'
return <Component {...rest}>{children}</Component>
}
// Button component
type CustomButtonProps = {
children: React.ReactNode
color?: 'primary' | 'secondary'
size?: 'sm' | 'md' | 'lg'
}
type ButtonProps<C extends React.ElementType> = PolymorphicComponentPropsWithRef<C, CustomButtonProps>
type ButtonComponent = <C extends React.ElementType = 'button'>(props: ButtonProps<C>) => React.ReactElement | null
const Button: ButtonComponent = React.forwardRef(<C extends React.ElementType = 'button'>(props: ButtonProps<C>, ref: PolymorphicRef<C>) => {
const { as, children, ...rest } = props
const Component = as || 'button'
return (
<Component ref={ref} {...rest}>
{children}
</Component>
)
})
Congratulation, we have successfully built a strongly typed Polymorphic React Component with Typescript. 🎉🎉🎉🎉