Creating fast type-safe polymorphic components

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 wo…


This content originally appeared on DEV Community 👩‍💻👨‍💻 and was authored by Nashe Omirro

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.

You can also just look at the final code here if you want.

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,
  });
};

// Usage:
<Button color="red">Fwoop</Button>
<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,
  });
};

// Usage: No Errors!
<Button color="green" aria-hidden>
  Fwoop
</Button>
<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:

// 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 each to 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} />;
};

Gotchas

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. If you want the final code I have it here.

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~


This content originally appeared on DEV Community 👩‍💻👨‍💻 and was authored by Nashe Omirro


Print Share Comment Cite Upload Translate Updates
APA

Nashe Omirro | Sciencx (2022-11-16T01:17:51+00:00) Creating fast type-safe polymorphic components. Retrieved from https://www.scien.cx/2022/11/16/creating-fast-type-safe-polymorphic-components/

MLA
" » Creating fast type-safe polymorphic components." Nashe Omirro | Sciencx - Wednesday November 16, 2022, https://www.scien.cx/2022/11/16/creating-fast-type-safe-polymorphic-components/
HARVARD
Nashe Omirro | Sciencx Wednesday November 16, 2022 » Creating fast type-safe polymorphic components., viewed ,<https://www.scien.cx/2022/11/16/creating-fast-type-safe-polymorphic-components/>
VANCOUVER
Nashe Omirro | Sciencx - » Creating fast type-safe polymorphic components. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2022/11/16/creating-fast-type-safe-polymorphic-components/
CHICAGO
" » Creating fast type-safe polymorphic components." Nashe Omirro | Sciencx - Accessed . https://www.scien.cx/2022/11/16/creating-fast-type-safe-polymorphic-components/
IEEE
" » Creating fast type-safe polymorphic components." Nashe Omirro | Sciencx [Online]. Available: https://www.scien.cx/2022/11/16/creating-fast-type-safe-polymorphic-components/. [Accessed: ]
rf:citation
» Creating fast type-safe polymorphic components | Nashe Omirro | Sciencx | https://www.scien.cx/2022/11/16/creating-fast-type-safe-polymorphic-components/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.