/react-polymorphic-component

Step by step building a fully/strong typed component capable to render a user selected element type.

Primary LanguageTypeScript

Set up

This a basic Vite app react/typescript flavor, so after cloning
just cd to folder project then run npm install and npm run dev
to have the dev server running

Polymorphic React Components

Since I came across these components in libraries like chakra ui and react bootstrap,
I was curious about this component that accepts this "as" property or
other name like "element" or "component" to allow the user to render a custom element,
I procrastinated researching this until I came across this article:
– How to Build Strongly Typed Polymorphic Components by author Emmanuel Ohans

So with this project, what I'll try to do is an implementation of some tricks I learned
from reading 👆️ that article.

Starting from common ground

Having a reusable React component(Typescript):

type MyComponentProps = {....}
const MyComponent = (props:PropsWithChildren<MyComponentProps>) => {
  const {children,...restButtonProps} = props
  return (
    <button {...restButtonProps}>
      {children}
    </button>
  )
}

This Function component will return a ReactElement that finally renders
the button element(jsx) into the corresponding DOM element, that all cool right
But what if you really want to provide your users with a flexible, reusable component.
What if you let the user decide whether the rendered DOM node will be an "a" a "p" or
any other native DOM element that fits the user's context instead of your choice(button)?

This is the functionality that will be provided by a polymorphic component

In the React neighborhood slang, a polymorphic is a component that can be rendered
with a list of different container elements.

I've personally encountered such components in my daily work with UI libraries
like Chakra UI and React-Bootstrap.



Version 0: Basic Start
type PolyButtonV0Props = {
  as: any;
};
const PolyButtonV0 = ({ as, children }: PropsWithChildren<PolyButtonV0Props>) => {
  const PolyButton = as ?? "button";
  return <PolyButton>{children}</PolyButton>;
};

notice:

  • Here the "as" prop let user to pass the element of choice: "div","p","a"...
  • "as" prop isn't rendered directly we used a capitalised var (jsx rules);

Implementation: src/App.tsx

function App() {
  return (
    <div className="App">
      <PolyButtonV0 as="div">Poly as div</PolyButtonV0>
      <PolyButtonV0 as="a">Poly as anchor</PolyButtonV0>
    </div>
  );
}

render this:

Apparently it's ok... pass "div" renders <div>, pass "a" renders <a>
but when you start playing with this find issues like:
if pass a wrong html element like 'magic'

<PolyButtonV0 as="magic">Poly as div</PolyButtonV0>

will render:

🥀 Not too promising...

No attribute support, e.g:

Version 1: Generic types

New Requirements:

  • "as" prop should not receive invalid HTML Element strings
  • Typescript types must detect incorrect attributes of valid elements

🤔 So as prop will only accept elements like: "div","p","a", so we can not know; seems like type "unknown" will fit the bill
meh, I pass "unknown" as Generic type:



React expects C to be an instance of React.Element, and C could be typed React.ElementType

type PolyButtonV1Props<C extends ElementType> = {
  as?: C;
};
const PolyButtonV1 = <C extends ElementType>({ as, children }: PropsWithChildren<PolyButtonV1Props<C>>) => {
  const PolyButton = as ?? "button";
  return <PolyButton>{children}</PolyButton>;
};

That fix previous error 'does not have any construct or call signature' and when the component is implemented, we got a good intellisense:



  • The prop string "as" will only accept valid HTML elements: "p", "h1", "a", etc and yells when
    pass "fake" 🎉
  • But if use as="a" href="https://....." typescript tell you that "href" props is not a valid prop

Make component capable of take valid attributes Ok, what we need is our component to accept the set of valid props based on "as" element selection React actually have a generic type named: ComponentProps
If we check react index.d.ts:

/**
 * NOTE: prefer ComponentPropsWithRef, if the ref is forwarded,
 * or ComponentPropsWithoutRef when refs are not supported.
 */
type ComponentProps<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>> =
  T extends JSXElementConstructor<infer P>
    ? P
    : T extends keyof JSX.IntrinsicElements
    ? JSX.IntrinsicElements[T]
    : {};

As you can see, types docs recommend use ComponentPropsWithoutRef instead of ComponentProps Do the refactor for that...

type PolyButtonV1Props<C extends ElementType> = {
  as?: C;
} & ComponentPropsWithoutRef<C>;
const PolyButtonV1 = <C extends ElementType>({
  as,
  children,
  ...restProps
}: PropsWithChildren<PolyButtonV1Props<C>>) => {
  const PolyButton = as ?? "button";
  return <PolyButton {...restProps}>{children}</PolyButton>;
};


Now our component only accepts valid elements for "as" prop and is aware of that "as"
selection element props... Great!

If we don't pass the "as" property, our PolyButtonV1 correctly creates a
"button" element. but if we try passing an "href" attribute the component won't show any error, that's bad🙍🏽.
The solution is quite simple, we just need to pass a generic type by default.



Now 👀


Now we are talking... if no prop "as" is passed our component renders by default a "button" element...
and of course, it will flag the error with any attribute that does not match that element. 🫁

Version 2: Component must handle own props

New Requirements:

  • The component must be able to handle its own props, such as color, in a type-safe way, of course.!

Let say our component will accept color prop. Color will be a pre-made list of colors let say "primary" and "accent"

small refactoring

An additional precaution: it is possible that some values that exist in ComponentPropsWithoutRef also exist
in the definition of the props type of our component. Instead of relying on our color prop to override what's coming from ComponentPropsWithoutRef, we better remove any type that also exit in our component types definition.

So, guess what... another refactoring

type PolyColor = "primary" | "accent";
type PolyButtonOwnProps<C extends ElementType> = { as?: C; color: PolyColor };
type PolyButtonV2Props<C extends ElementType> = PolyButtonOwnProps<C> &
  Omit<ComponentPropsWithoutRef<C>, keyof PolyButtonOwnProps<C>>;

const PolyButtonV2 = <C extends ElementType = "button">({
  as,
  children,
  style,
  color,
  ...restProps
}: PropsWithChildren<PolyButtonV2Props<C>>) => {
Version 3: Make component reusable, work with any component

**New requirements: **

  • If we want to make the component reusable, will need remove the PolyButtonOwnProps
    and represent that with a generic, so anyone can pass in whatever component props they need
type AsProp<C extends ElementType> = { as?: C };
type PolyButtonOwnProps<C extends ElementType, PassedProps = {}> = AsProp<C> & PassedProps;
type PolyButtonV3Props<C extends ElementType> = PropsWithChildren<PolyButtonOwnProps<C>> &
  Omit<ComponentPropsWithoutRef<C>, keyof PolyButtonOwnProps<C>>;

const PolyButtonV3 = <C extends ElementType = "button">({
  as,
  children,
  style,
  color,
  ...restProps
}: PolyButtonV3Props<C>) => {

Here we separate AsProp the type for "as" prop and PassedProps are the others props passed to the component(besides as);

After refactoring you have:

type AsProp<C extends ElementType> = { as?: C };
type PolyButtonOwnProps<C extends ElementType, PassedProps = {}> = AsProp<C> & PassedProps;
type PolyButtonV3Props<C extends ElementType, PassedProps = {}> = PropsWithChildren<
  PolyButtonOwnProps<C, PassedProps>
> &
  Omit<ComponentPropsWithoutRef<C>, keyof PolyButtonOwnProps<C>>;

const PolyButtonV3 = <C extends ElementType = "button", PassedProps = {}>({
  as,
  children,
  style,
  color,
  ...restProps
}: PolyButtonV3Props<C, PassedProps>) => {
  const PolyButton = as ?? "button";
  const outStyle = color ? { ...style, color: color === "primary" ? "#058ed9" : "#df6066" } : style;
  return (
    <PolyButton style={outStyle} {...restProps}>
      {children}
    </PolyButton>
  );
};

Now if you build another component, you can give it Polymorphic powers like this:

PolyButtonV3Props<C,MyNewComponentPropsType>

Version 4: Component should support Refs

Remember in Version #1 when we implemented the ComponentPropsWithoutRef, you know first we look for just ComponentProps, but the types documentation recommended the one
with the ...WithoutRef, well our next step deal with refs

The way refs works in React is that you just don't pass "ref" as prop to custom
components, like any other prop. Instead you handle "refs" in your functional components by using forwardRef function

lets code this... 🧑‍💻



Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

We need to type the "ref" extra prop from "forwardRef"



But what we really need is the type for "ref" prop.



Cool! now our component can take a "ref" prop, but if you place your cursor over the ref={} you'll notice:

React.RefAttributes<unknown>.ref?: React.Ref<unknown>

That unknown is a sign of weak typing... let's fix it

  • The types for other props of the PolyButtonV4 still reference the PolyButtonV4Props
  • Lets create a new type PolymorphicComponentPropWithRef that just be the union of of PolyButtonV4Props and the "ref" prop
type PolymorphicComponentPropWithRef<C extends ElementType, PassedProps = {}> = PolyButtonV4Props<
  C,
  PassedProps
> & { ref?: PolymorphicRef<C> };

Then, we change component props to reference this new PolymorphicComponentPropWithRef type

type PolyButtonProps<C extends ElementType> = PolymorphicComponentPropWithRef<
  C,
  { color: PolyColor | "black" }
>;
const PolyButtonV4 = forwardRef(
  <C extends ElementType = "button", PassedProps = {}>(
    { as, children, style, color, ...restProps }: PolyButtonProps<C>,
    ref?: PolymorphicRef<C>
  ) => {

Finally, create a type annotation for the component:

type PolymorphicButton = <C extends ElementType = "button">(props: PolyButtonProps<C>) => ReactElement | null;

Type the component with this new type

const PolyButtonV4: PolymorphicButton = forwardRef(
  <C extends ElementType = "button", PassedProps = {}>(
    { as, children, style, color, ...restProps }: PolyButtonProps<C>,
    ref?: PolymorphicRef<C>
  ) => {
    const PolyButton = as ?? "button";

And BUMMM 🚀 Now we got a fully typed polymorphic component because the "ref" property infer the correct type dependant on the "as" element of choice