DEV Community 👩‍💻👨‍💻

Cover image for Creating fast type-safe polymorphic components using render props
Nashe Omirro
Nashe Omirro

Posted on • Updated on

Creating fast type-safe polymorphic components using render props

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,
  });
};
Enter fullscreen mode Exit fullscreen mode
// <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>
Enter fullscreen mode Exit fullscreen mode

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' />
Enter fullscreen mode Exit fullscreen mode

We can try adding those in to Props with the help of ComponentPropsWithoutRef:

type Props = ComponentPropsWithoutRef<'button'> & {
  color: "red" | "green";
  // ...
};

Enter fullscreen mode Exit fullscreen mode

But this would mean we can do this with no errors:

<Button
  onClick={onClick}
  color="red"
  aria-hidden
  render={(props) => <a {...props} />}
>Fwoop</Button>;
Enter fullscreen mode Exit fullscreen mode

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,
  });
};
Enter fullscreen mode Exit fullscreen mode
// 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>
Enter fullscreen mode Exit fullscreen mode

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:

// 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;

Enter fullscreen mode Exit fullscreen mode

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,
  });
};
Enter fullscreen mode Exit fullscreen mode

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} />;
};
Enter fullscreen mode Exit fullscreen mode

Now we can just do:

const defaultButton =
  createDefaultRender<Overwrite<DefaultProps, InjectedProps>>(
    'button',
  );

Enter fullscreen mode Exit fullscreen mode

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];
};
Enter fullscreen mode Exit fullscreen mode

and then we can change our DefaultProps to:

type DefaultProps = ComponentPropsWithInnerRef<'button'>;
Enter fullscreen mode Exit fullscreen mode

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} />;
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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~

Top comments (0)

Hey 😍

Want to help the DEV Community feel more like a community?

Head over to the Welcome Thread and greet some new community members!