Creating fast type-safe polymorphic components using render props
Tags: react, typescript, webdev
Published: 2022-11-16 01:17:51

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](https://www.npmjs.com/package/react-polymorphed) 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](https://blog.logrocket.com/build-strongly-typed-polymorphic-components-react-typescript/).

**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)"](https://blog.andrewbran.ch/polymorphic-react-components/) 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"](https://reactjs.org/docs/render-props.html) technique, here's a simple demonstration:
```tsx
// 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,
  });
};
```
```tsx
// <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?
```tsx
// 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`:
```tsx
type Props = ComponentPropsWithoutRef<'button'> & {
  color: "red" | "green";
  // ...
};

```
But this would mean we can do this with no errors:
```tsx
<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:
```tsx
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,
  });
};
```
```tsx
// 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 no `render` prop ✔

But our solution is a little messy, let's tidy up with some utility types:
```tsx
// 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:
```tsx
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:
```tsx
/**
 * 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:
```tsx
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](https://stackoverflow.com/questions/58469229/react-with-typescript-generics-while-using-react-forwardref). 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:
```tsx
/**
 * 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:
```tsx
type DefaultProps = ComponentPropsWithInnerRef<'button'>;
```
If you're also using the `createDefaultRender` function we can change that so that it assigns 'innerRef' to ref:
```tsx
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:
```tsx
<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](https://github.com/andrewbranch/merge-props) together.

```tsx
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~