Creating fast type-safe polymorphic components using render props
Nashe Omirro
Posted on November 16, 2022
UPDATE: After a few weeks of researching more about polymorphic components and other implementations, digging through source code and all, I made a typescript package for creating polymorphic components that uses the as
prop, and better yet, doesn't slow down typescript at all, go check it out!
Spoiler alert: We won't be using the as
prop for this one, if you were looking for a solution that mimics the implementation of styled-components
or other component libraries, you can check out this very detailed guide by Ohans Emmanuel.
HOWEVER, I wouldn't recommend the pattern, simply because it makes typescript significantly slow and if you're using average to low-end devices, it just kills DX. Truth be told though that I have not looked for better as
prop implementations that does not impede typescript.
This post is basically just a more verbose solution from this article, "Writing Type-Safe Polymorphic React Components (Without Crashing Typescript)" by Andrew Branch, it explains in more detail than I ever could, on why the as
prop is slow and his solution to the problem. I also recommend to check that out first if you want more context.
Expected Behavior
Before starting, we first need to know what polymorphic components are:
- we can override the component to render something else.
- It has a default render, a
<Button>
is a button until we say otherwise. - If we don't override the component, it should have default prop types, and once we do override it, strip those away and replace them with the props of our override.
- We might also pass props that are required by the component no matter what we are rendering.
Implementation
Okay, now that we got that over, here comes the fun part! The core concept is to pass in a function that our polymorphic component will call, more widely known as the "render prop" technique, here's a simple demonstration:
// the props we pass to our render function
type InjectedProps = {
className: string;
children?: React.ReactNode;
};
// the props we pass to our Button
type Props = {
color: 'red' | 'green';
children?: React.ReactNode;
render?: (props: InjectedProps) => React.ReactElement;
};
// the default render function for rendering a button
const defaultButton = (props: InjectedProps) => <button {...props} />;
const Button = ({
color,
children,
render = defaultButton,
}: Props) => {
// calls render, in which if undefined is going to render our
// defaultButton
return render({
className: getClassName(color),
children,
});
};
// <button className="red-button">Fwoop</fwoop>
<Button color="red">Fwoop</Button>
// <a className="green-button">Fwoop</a>
<Button color="green" render={(props) => <a {...props} />}>
Fwoop
</Button>
Instead of using an as
prop, we pass in a render
prop that the polymorphic component calls. In the second use of our <Button />
we render an anchor tag instead.
The actual component is stored on the render
prop, and Button
is only used to calculate the props that will be passed to our render
.
Using Generics
But what if you want to pass our <Button />
some button attributes?
// type and onClick isn't part of the props
<Button onClick={onClick} type='submit' color='green' />
We can try adding those in to Props
with the help of ComponentPropsWithoutRef
:
type Props = ComponentPropsWithoutRef<'button'> & {
color: "red" | "green";
// ...
};
But this would mean we can do this with no errors:
<Button
onClick={onClick}
color="red"
aria-hidden
render={(props) => <a {...props} />}
>Fwoop</Button>;
Now in here we have 2 choices, either have the Button
change it's props according to whatever the render
prop returns or just write those attributes inside the <a />
tag instead. If we go with the former, not only will that be harder than implementing the as
prop, we also won't get the performance benefits this pattern brings. So, we are going for option #2
Which means that we shouldn't be able to pass in those props if we have a custom render, moreover, we aren't passing those props to render in the first place. Let's fix that:
import { ComponentPropsWithoutRef } from 'react';
type InjectedProps = {
className: string;
children?: React.ReactNode;
};
type DefaultProps = ComponentPropsWithoutRef<'button'>;
// let's place the render type here for re-use
type RenderFn = (props: InjectedProps) => React.ReactElement;
type Props = {
color: 'red' | 'green';
children?: React.ReactNode;
render?: RenderFn;
};
// let's also make sure default button has default props as well.
const defaultButton = (props: InjectedProps & DefaultProps) => (
<button {...props} />
);
// checks if render is undefined, if it is then we should also
// have default props.
const Button = <T extends RenderFn | undefined>({
render = defaultButton,
color,
...props
}: T extends undefined
? DefaultProps & Props
: Props & { render: T }) => {
return render({
className: getClassName(color),
...props,
});
};
// Has button attributes by default
<Button color="green" aria-hidden>
Fwoop
</Button>
// Is rendered as an anchor tag
<Button color="red" render={() => <a aria-hidden />}>
Fwoop
</Button>
// Typescript will complain about the type attribute
<Button
type='button'
color="red"
render={() => <a aria-hidden />}
>
Fwoop
</Button>
By using generics, we can determine if the render
prop was passed or not. If it wasn't, our props would be of type: DefaultProps & Props
, but if we did pass one, the DefaultProps
get's stripped away.
note that including { render: T }
is important, it tells typescript which prop we should check for.
Tidying up
With the above solution, that pretty much checks all of the goals we've set:
- our
Button
is overridable β - our
Button
is a button until we render something else β - if we did pass a render function we strip away the default props and also only passing the important bits β
- and we still require a color to be passed,
render
prop or norender
prop β
But our solution is a little messy, let's tidy up with some utility types:
// utils.ts
export type RenderProp<T extends Record<string, unknown>> = (
props: T,
) => React.ReactElement | null;
export type PropsWithRender<
IP extends Record<string, unknown> = {},
P extends Record<string, unknown> = {},
> = P & {
/** when provided, render this instead
* with injected props passed to it. */
render?: RenderProp<IP>;
children?: React.ReactNode;
};
/** Intersect A & B but with B
* overriding A's properties in case of conflict */
export type Overwrite<A, B> = Omit<A, keyof B> & B;
And we can use them like so:
type InjectedProps = PropsWithChildren<{ className: string }>;
type DefaultProps = ComponentPropsWithoutRef<'button'>;
type Props = PropsWithRender<
InjectedProps,
{
color: 'red' | 'green';
}
>;
const defaultButton = (
props: Overwrite<DefaultProps, InjectedProps>,
) => <button {...props} />;
const Button = <T extends RenderProp<InjectedProps> | undefined>({
render = defaultButton,
color,
...props
}: T extends undefined
? Overwrite<DefaultProps, Props>
: Props & { render: T }) => {
return render({
className: getClassName(color),
...props,
});
};
One thing I didn't like to keep doing was having to write JSX for our default render function, so I wrote a little utility to help with that:
/**
* Creates a render function given a react element type,
* types are very loose on this function so make sure to give
* it the proper `P` for the `Component` passed
* because typescript won't complain if they don't match.
*/
export const createDefaultRender = <P extends Record<string, unknown>>(
Component: React.ElementType,
): RenderProp<P> => {
return (props: P) => <Component {...props} />;
};
Now we can just do:
const defaultButton =
createDefaultRender<Overwrite<DefaultProps, InjectedProps>>(
'button',
);
Yeah, maybe its not too much of an improvement but to each their own.
with forwardRef
Unfortunately, forwardRef()
and generics don't mix at all and that there are multiple ways to potentially solve this problem. Me being a lazy ass went for the easiest choice... not using forwardRef()
at all and just using a custom prop named innerRef
.
If you also chose the big-brain solution then here's some more utility types you can use:
/**
* just like `ComponentPropsWithRef<T>` but with `ref` key
* changed to `innerRef`.
*/
export type ComponentPropsWithInnerRef<T extends React.ElementType> = {
[K in keyof ComponentPropsWithRef<T> as K extends 'ref'
? 'innerRef'
: K]: ComponentPropsWithRef<T>[K];
};
and then we can change our DefaultProps
to:
type DefaultProps = ComponentPropsWithInnerRef<'button'>;
If you're also using the createDefaultRender
function we can change that so that it assigns 'innerRef' to ref:
export const createDefaultRender = <
P extends Record<string, unknown> & { innerRef?: unknown },
>(
Component: React.ElementType,
): RenderProp<P> => {
return ({ innerRef, ...props }) =>
<Component ref={innerRef} {...props} />;
};
A small caveat
There is a possibility that we might override props unintentionally, take a look at this instance:
<Button
color="red"
render={(props) => <a {...props} className='my-button' />}
>
Fwoop
</Button>
O-Oh! The className from ...props
got overwritten with 'my-button'. Sometimes this might be what we want but most of the time we usually want them together, this is also the case if we had injected props that have event listeners and what not.
Luckily for us, the man who I got this solution from already made a small utility function that merge-props together.
import mergeProps from "merge-props";
<Button
color="red"
render={(props) => <a {...mergeProps(props, {
className: 'my-button'
}) />}
>
Fwoop
</Button>
Conclusion
Yep, that was.. a lot, if you're a bit confused, don't worry, I am too but it does make more sense if you keep writing them this way.
Thanks for reading, and I'm sure that there are probably ways we could have implemented this better, so please write them in the comments for I too don't really know what I'm doing~
Posted on November 16, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
August 14, 2024