Deconstructing Material UI's OverridableComponent - the key to polymorphic components.
Monday 9th June 2025
It annoys me when things do not make sense, and I don't get that "click" of understanding. At work I've started building out a new component library, and a friend of mine @penx introduced me to the concept of polymorphic components, highlighting how the Material UI library implements them. Specifically, he directed me to the types used to implement them.
This absolute masterpiece of a type `OverridableComponent` and it's associated utility types had me somewhat confused. I could use it, but didn't fully understand it, so it was time to break it down.
Credit
At this point, I must point out full credit for this code goes to the original authors over at the Material UI project. I am simply breaking down someone else's code in an effort to properly understand it.
The Problem
There is one problem we are trying to solve here. Changing a React components' props, based on the value passed to a specific prop.
For example, I have a button component, implemented like this.
// Our fixed props
type Button {
onClick: () => void;
}
// Our fixed components
const Button = (props) => {
const { onClick } = props;
return (
<button onClick={onClick} className="button">
{props.children}
</button>
);
};
// Usage
<Button onClick={alert('a link')}}>A link</Button>
Now that is all fine, until I want my button to actually be an Anchor tag. Or I'm integrating my component library into a React Router project that needs to actually return a `Link`
?
There are a few ways we could go about this.
I could export multiple variations of a Button from my library. But this would result in a confusing amount of exports `NextButton` `LinkButton` etc...
Export just a wrapper with styles, then allow the consumer to pass in the element through the children props. Radix's `asChild` is this.
Pass a `component` prop in, just like Material UI does it.
Material UI allows you to pass a `component`
(or has been named `as`
) prop, with a component. This means you can do fantastic things like:
// React Router https://api.reactrouter.com/v7/functions/react_router.Link.html
import { Link } from "react-router";
// 1 - This <Button> renders out as a <Link>
// 2 - We get strict type checking for our `to` prop, inherited from the Link component.
<Button as={Link} to={'/home'} >Go Home</Button>
// Standard Button - renders out a <a>
<Button as='a' href='/home'>Go Home</Button>
When it comes to a component library this is fantastic. It means:
We can keep exporting a single
<Button>
component.It can integrate with any 3rd party library by allow the user to override the underlying element
Developers still get full type checking and autocomplete rather than just falling back on
`any`
.
The Solution
How does Material UI implement this? They have this masterpiece of a type.
export interface OverridableComponent<TypeMap extends OverridableTypeMap> {
// If you make any changes to this interface, please make sure to update the
// `OverridableComponent` type in `mui-types/index.d.ts` as well.
// Also, there are types in Base UI that have a similar shape to this interface
// (for example SelectType, OptionType, etc.).
<RootComponent extends React.ElementType>(
props: {
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: RootComponent;
} & OverrideProps<TypeMap, RootComponent>,
): React.JSX.Element | null;
(props: DefaultComponentProps<TypeMap>): React.JSX.Element | null;
}
Which lets you implement a component like this.
type ButtonImpl = OverridableComponent<ButtonTypeMap>;
type ButtonProps = OverrideProps<ButtonTypeMap, "button">;
const Button: ButtonImpl = (props: ButtonProps) => {
const {
component: Component = "button",
...rest
} = props;
return (
<Component
className={className}
{...rest}
/>
);
};
How have they done this? Well, before we look at how that solution works, lets quickly summarise the three problems we need to solve to achieve this elegance.
Add the `component`'s props, to our props.
Remove any duplicates along the way.
Ensure the component is versatile enough to provide correct props & defaults when no
`component`
prop is passed.
The Implementation
Step 1 - Enable the `component` prop, and combine prop values.
The first step is to add an interface, using generics to our `OverrideableComponent`
type.
interface OverrideableComponent {
// C is inferred from the value passed to `component`
<C extends React.ElementType>(
props: {
component: C;
} & React.ComponentPropsWithRef<C>
): React.JSX.Element | null;
}
This interface, with that overload actually enables the majority of what we want.
Inferred Generics
One thing that kept confusing me, was how does `C
` get it's value, when you can't explicitly pass it in? Well, Typescript infers the value of C
by whatever is passed into `component`
. It is inferred. This is incredibly useful, as we can then reference this in `React.ComponentPropsWithRef<C>`
Have a read of this https://www.typescriptlang.org/docs/handbook/2/generics.html#generic-types for more info
// Example of an OverridableComponent type
const Example: OverrideableComponent = (props) => {
const { component: Component, ...rest } = props;
return <Component {...rest} />;
};
// Example component to demonstrate props usage
const TestComponentWithProps = ({
abc,
}: {
abc: string;
children: React.ReactNode;
}) => {
return <div>{abc}</div>;
};
// Example usage
const ExampleUsage = () => {
return (
// Strict types only here, abc is a string. abc={1} will fail.
<Example component={TestComponentWithProps} abc="123">
Hello, World!
</Example>
);
};
If we wanted to enforce the user to always pass a `component` prop, we'd be done now.
However we still have two problems. We don't have a default component to use, and we may have conflicting props between our root component, and whatever component is passed in by our end user.
Step 2 - Setting some defaults.
To solve the first problem of no default component, Material UI adds some type utilities to use.
// 1 - Add OverridableTypeMap interface
// This allows components implementing our Overrideable patter to specify their own props, and also the type of the fallback to be used.
export interface OverridableTypeMap {
// The props that the component accepts
props: {};
// The default component type that will be used if no `component` prop is provided
defaultComponent: React.ElementType;
}
// 2 - BaseProps is a utility type that extracts the props from the OverridableTypeMap
export type BaseProps<M extends OverridableTypeMap> = M["props"];
// 3 - Remove properties `K` from `T`.
export type DistributiveOmit<T, K extends keyof any> = T extends any
? Omit<T, K>
: never;
Then, it updates the OverrideableComponent
interface with this.
// Props if `component={Component}` is NOT used.
// We combined the "props" from our default, with the "props" from our component.
// This could for example combine the HTML props from our default fallback of a "button" element.
export type DefaultComponentProps<M extends OverridableTypeMap> = BaseProps<M> &
DistributiveOmit<
React.ComponentPropsWithRef<M["defaultComponent"]>,
keyof BaseProps<M>
>;
// Props of the component if `component={Component}` is used.
// We combined the "props" out of our TypeMap, and the "props" from our component value, with duplicates/conflicts removed.
export type OverrideProps<
M extends OverridableTypeMap,
C extends React.ElementType,
> = BaseProps<M> &
DistributiveOmit<React.ComponentPropsWithRef<C>, keyof BaseProps<M>>;
// Updated OverrideProps
// This now includes our TypeMap type, and an interface for when no `component` prop is provided
interface OverrideableComponent<TypeMap extends OverridableTypeMap> {
<RootComponent extends React.ElementType>(
props: {
component: RootComponent;
// We cannot continue to use React.ComponentPropsWithRef here, it needs to be more specific so Typescript infers the correct interface.
} & OverrideProps<TypeMap, RootComponent>
): React.JSX.Element | null;
// New interface for when no `component` prop is provided
(props: DefaultComponentProps<TypeMap>): React.JSX.Element | null;
}
There are a few things going on here.
We now have a new, more generic overload added to
`OverrideableComponent`
that applies when no`component`
prop is passed.However, to make Typescript infer this correctly, we need to adjust our first overload and update the type of props to be
`& OverrideProps<TypeMap, RootComponent>`
rather than just`& React.ComponentPropsWithRef<RootComponent>`
. This is because`ComponentPropsWithRef`
will fail, but silently, with a value ofundefined
. So, if we don't pass a `component` prop in, the first interface still matches as far as TypeScript is concerned. I don't fully understand this, but it's something to do with generics and how Typescript infers the correct overload with an undefined value.
ComponentPropsWithRef vs ComponentProps
There's no particular reason I'm using ComponentPropsWithRef
, it would work just as well with ComponentPropsWithoutRef
or ComponentProps.
Our updated component implementation and usage now looks like this
// Updated component props.
type ExampleTypeMap = {
props: {
children?: React.ReactNode;
component?: React.ElementType;
one?: string;
};
defaultComponent: "button";
};
// Make use of our BaseProps utility.
type ExampleProps = BaseProps<ExampleTypeMap>;
// Example of an OverridableComponent type
const Example: OverrideableComponent<ExampleTypeMap> = (
props: ExampleProps
) => {
const { component: Component = "button", one, ...rest } = props;
return <Component {...rest} />;
};
// Example usage
const ExampleUsage = () => {
return (
// If "abc" was uncommented without "component", we would get the type error "Property 'abc' does not exist on type 'IntrinsicAttributes & { children?..."
<Example
// component={TestComponentWithProps}
// abc="123"
one="test"
onClick={() => alert("abc")}
>
Hello, World!
</Example>
);
};
You can see this working correctly, because depending on the value our ExampleTypeMap sets for the `defaultComponent`
('button', or `div` for example), the `onClick`
event parameter type changes accordingly, eg `onClick?: React.MouseEventHandler<HTMLButtonElement>`
or `onClick?: React.MouseEventHandler<HTMLDivElement>`
Conclusion
Well, deconstructing that certainly made me realise a couple of things.
Typescript inference is incredibly powerful, although somewhat of a black-box in terms of behaviour, and can certainly do some odd & unexpected things.
Generics, we utilised correctly, can make components incredibly versatile.
Further Reading
Since writing this article, I have come across this which does an even better job of explaining it than I do. I recommend a read - https://blog.logrocket.com/build-strongly-typed-polymorphic-components-react-typescript/
Also another good read on Polymorphic components as a whole - https://itnext.io/react-polymorphic-components-with-typescript-f7ce72ea7af2