Enhancing Reusability with the Polymorphic Component Pattern

CNHur HyeonBin (Max)
Reponses  01month ago( Both Korean and English Version )

Introduction

Styled-components are being used at a very high frequency in terms of improving the convenience of project development and overall design completeness. I also enjoy using them very much, with representative libraries being MUI and Shadcn. When developing with such libraries, situations often arise where the basic functionality provided by the library doesn't match project requirements, necessitating modifications to the internal code. When examining library source code, you encounter patterns that are difficult to understand, with the Polymorphic component pattern being a representative example.

Polymorphic Component is a design pattern that allows flexible changes to a component's semantic tags and behavioral logic based on conditions, which aligns well with React's core philosophy of maximizing component reusability. A literal translation of 'Polymorphic' means "capable of changing into various forms". In other words, there are times when a single component needs to be represented as a 'div' tag in some situations, as a 'button' tag in others, and even as an 'anchor' tag in further cases. 

Another example would be when a certain component needs to be rendered as an 'h1' tag, 'h2' tag, or 'h3' tag depending on where it's positioned. Such situations where a single component must take different forms depending on the context in which it's used occur very frequently in actual development, and there are various ways to solve this.

Solutions:

  1. Implement all components for every situation and render differently based on if statements.
  2. Implement only one component using the Polymorphic component pattern and render differently based on conditions.

While method 1 can solve the presented problem situation, it creates a lot of duplicate code and can result in countless components that serve the same role but differ only in semantic tags depending on the situation. For developers, having more components and more code means difficulties in future maintenance, which is also far from React's philosophy of reusability.
Method 2 has the advantages of minimizing duplicate code, dramatically reducing code volume, and improving maintainability (though it does have the drawback of increased development difficulty). Let's explore how to implement this excellent polymorphic component.

 


 

Basic Implementation of Polymorphic Component Pattern

Before diving into actual implementation, this pattern truly shines when implemented with TypeScript. Since implementing it in JS is very easy, we'll learn the pattern in JS first and gradually work our way up to TS. Let's explore everything from basic to advanced implementations step by step.

There are 3 representative situations commonly addressed in Polymorphic implementation.

  • Situation where there's 1 component that needs semantic tag changes
  • Situation where there are multiple components that need semantic tag changes
  • Situation where the DOM element of the changing component needs to be connected to the parent's ref

 

Situation where there's 1 component that needs semantic tag changes

Let's assume an example problem situation where tags need to change to h1, h2, h3 tags depending on the situation.

function App() {
 return (
   <>
     <HeadingShouldBeH1 />
     <HeadingShouldBeH2 />
     <HeadingShouldBeH3 />
   </>
 );
}
export const HeadingShouldH1 = () => {
 const h = "It should be h1";
 return (
   <div>
     <Heading heading={h} />
   </div>
 );
};
export const Heading = ({ heading }) => {
	return <h1>{heading}</h1>;
};

 

In the current situation, there's a problem where the 'Heading' component should be displayed with different semantic tags depending on where it's positioned. This can be easily solved using the most basic Polymorphic component pattern by passing which tag the Heading component should be wrapped in as props. 

export const Heading = ({ heading, as }) => {
	const Tag = as;
	return <Tag>{heading}</Tag>;
};
export const HeadingShouldBeH1 = () => {
 const h = "It should be h1";
 return (
   <div>
     <Heading heading={h} as={"h1"} />
   </div>
 );
};


export const HeadingShouldBeH2 = () => {
 const h = "It should be h2";
 return (
   <div>
     <Heading heading={h} as={"h2"} />
   </div>
 );
};

export const HeadingShouldBeH3 = () => {
 const h = "It should be h3";
 return (
   <div>
     <Heading heading={h} as={"h3"} />
   </div>
 );
};

 

This is very simple code that needs no explanation, and the results are as follows.

polymorphic component js first case.png

 

Even though it's the same component, it was correctly rendered with the appropriate semantic tags for each different situation through the 'as' property, and the difficulty level is not challenging either.

 

Situation where there are multiple components that need semantic tag changes

Now let's add one more condition. What if the component that needs to change based on situations isn't just 'Heading'? Let's create a MyLink component that needs to render Button, Anchor, and Div tags according to conditions.

export const MyLink = ({ as, ...props }) => {
  const Tag = as;
  return <Tag {...props}>MyLink</Tag>;
};
export const HeadingShouldBeH1 = () => {
 const h = "It should be h1";
 return (
   <div>
     <Heading heading={h} as={"h1"} />
     <MyLink as={"a"} href="/somepage" />
   </div>
 );
};


export const HeadingShouldBeH2 = () => {
 const h = "It should be h2";
 return (
   <div>
     <Heading heading={h} as={"h2"} />
     <MyLink as={"button"} onClick={()=>console.log("button clicked")} />
   </div>
 );
};

export const HeadingShouldBeH3 = () => {
 const h = "It should be h3";
 return (
   <div>
     <Heading heading={h} as={"h3"} />
     <MyLink as="div" />
   </div>
 );
};

'MyLink' should display different semantic tags and behave differently depending on where it's positioned. 

Looking at the 'MyLink' and 'Heading' components, they are very similar. Both just receive 'as' from their parent and display accordingly. Right now there are only two, but if there were 50 such components, we'd need to create 50 components with identical logic, which can be easily solved by adding just one level to eliminate duplicate logic.

export const Polymorphic = ({ as, ...props }) => {
  const Tag = as;
  return <Tag {...props}>{props.children}</Tag>;
};

export const Heading = ({ heading, as }) => {
  return <Polymorphic as={as}>{heading}</Polymorphic>;
};

export const MyLink = ({ as, ...props }) => {
  return (
	<Polymorphic as={as} {...props}>
	  MyLink
	</Polymorphic>
  );
};

export const HeadingShouldBeH1 = () => {
  const h = "It should be h1";
  return (
	<div>
	  <Heading heading={h} as={"h1"} />
	  <MyLink as={"a"} href="/somepage" />
	</div>
  );
};

Polymorphic second case.png

 

Looking at the current component tree: 

Polymorphic component diagram:tree.png

 

Situation where the DOM element of the changing component needs to be connected to the parent's ref

Finally, the last situation to consider is when the parent component needs to access the DOM of the child component. Generally, accessing the DOM means using ref to connect a specific element of the child component to the parent component's ref.

In React, ref is classified differently from regular values, and the way it's passed between components also differs. When passing ref to a direct child, it can be passed using the regular property passing method, but when passing ref through multiple components, using the regular method might cause the ref to be unintentionally transformed during transmission. To solve this, React provides a special API called 'forwardRef' that allows ref to be passed through multiple components without transformation. Since the current situation requires passing ref multiple times through 'HeadingShouldBeH1'->'Heading'->'Polymorphic', we need to use 'forwardRef'.

(React 19 is reportedly improving the way ref is passed through multiple components. While we currently need to pass ref using the method below, it will likely be improved in future versions.)

Polymorphic Component tree 2.png

 

By wrapping intermediate bridge components with forwardRef, ref can be successfully passed all the way to the bottom-level 'Polymorphic'.

export const Heading = forwardRef(({ heading, as }, ref) => {
  return (
	<Polymorphic ref={ref} as={as}>
	  {heading}
	</Polymorphic>
  );
});

export const MyLink = forwardRef(({ as, ...props }, ref) => {
  return (
	<Polymorphic ref={ref} as={as} {...props}>
	  MyLink
	  </Polymorphic>
  );
});

 

Implementing Polymorphic component with TypeScript

This time, we'll skip the basic situations and start with the most complex situation from the previous examples - the situation where ref needs to be passed.

What we need to pay close attention to here is that excluding the App component, we need to implement the Polymorphic component pattern through a total of 3 levels, which I'll name Level1 through Level3.

  • Level1: HedingShouldBeH1, HeadingShouldBeH2, HeadingShouldBeH3
  • Level2: Heading, MyLink
  • Level3: Polymorphic

 

Type Definition Focused on As and Ref

Polymorphic Component tree 3.pngFirst, before diving into complex type definitions, let's look at simple types. The difference between Level 2 and Level 3 is that Level 2 receives ref using forwardRef, so it needs to receive as and ref separately. However, Level 3 can receive as and ref together. This is how forwardRef is used.

First, we'll create PolymorphicAs type made of React Elements and PolymorphicRef type made of React ref, then properly define them for each Level.

export type PolymorphicAs<T extends React.ElementType> = {
	as?: T;
};

export type PolymorphicRef<T extends React.ElementType> =
	React.ComponentPropsWithRef<T>["ref"];
export const MyLink = forwardRef(
 <T extends React.ElementType = "a">(
   { as, ...props }: PolymorphicAs<T>,
   ref: PolymorphicRef<T>["ref"]
 ) => {
   return (
     <Polymorphic ref={ref} as={as || "a"} {...props}>
       MyLink
     </Polymorphic>
   );
 }
);
type Level3ComponentProps<T extends React.ElementType> = PolymorphicAs<T> &
 PolymorphicRef<T>;

export const Polymorphic = <T extends React.ElementType = "div">({
 as,
 ...props
}: Level3ComponentProps<T>) => {
 const Tag = as || "div";
 return <Tag {...props}>{props.children}</Tag>;

 

 

Reading the code, we simply defined PolymorphicRef and PolymorphicAs as separate Utility Types and positioned them in the correct places. 

 

Adding Regular Props

However, it's more common to need to receive several additional props along with as and ref. In the actual example, MyLink sometimes needs to be an 'a' tag that receives href as props, and there's also a button tag that receives onClick. Here, let's add a property to MyLink that receives specific props to make the link disabled, and receive a user name to append it after the link.

export const MyLink = forwardRef(
 <T extends React.ElementType = "a">(
   { as, isDisabled = false, userName, ...props }: PolymorphicAs<T>,
   ref: PolymorphicRef<T>["ref"]
 ) => {
   return (
     <Polymorphic disabled={isDisabled} ref={ref} as={as || "a"} {...props}>
       MyLink {userName}
     </Polymorphic>
   );
 }
);

Now summarizing, MyLink can receive a total of 4 fixed props and 2 optional props.

  • as: React Element 
  • ref: React ref
  • isDisabled: boolean
  • userName: string
  • href?: string
  • onClick?: () => void

In such situations, Level2 should bundle all types except ref together and pass ref separately. For Level3, since it's not a special situation, it can receive all properties at once.

Polymorphic Component tree 4.png

type _MyLinkProps = {
 isDisabled: boolean;
 userName: string;
 href?: string;
 onClick?: ()=>void;
};

export type MyLinkProps<T extends React.ElementType> = PolymorphicAs<T> &
 _MyLinkProps;

export const MyLink = forwardRef(
 <T extends React.ElementType = "a">(
   { as, isDisabled = false, userName, ...props }: MyLinkProps<T>,
   ref: PolymorphicRef<T>["ref"]
 ) => {
   return (
     <Polymorphic disabled={isDisabled} ref={ref} as={as || "a"} {...props}>
       MyLink {userName}
     </Polymorphic>
   );
 }
);

Level 3 doesn't need any type definition changes since it's a very flexible component that doesn't receive fixed props. This way, tight type definitions are possible at intermediate stages while Level3 maintains flexible type definitions.

The final code (including important parts) would be as follows.

// App Component
function App() {
  return (
	<>
	  <HeadingShouldBeH1 />
	  <HeadingShouldBeH2 />
	  <HeadingShouldBeH3 />
	</>
  );
}

// HeadingShouldBeH1 Component

export const HeadingShouldBeH1 = () => {
  const h = "It should be h1";
  const headingRef = useRef(null);
  const linkRef = useRef(null);
  return (
	<div>
	  <Heading ref={headingRef} heading={h} as={"h1"} />
	  <MyLink
	    ref={linkRef}
	    isDisabled={true}
		userName="max"
		as={"a"}
		href="/somepage"
	  />
	</div>
  );
};


// MyLink Component
type _MyLinkProps = {
 isDisabled: boolean;
 userName: string;
 href?: string;
 onClick?: ()=>void;
};

export type MyLinkProps<T extends React.ElementType> = PolymorphicAs<T> &
 _MyLinkProps;

export const MyLink = forwardRef(
 <T extends React.ElementType = "a">(
   { as, isDisabled = false, userName, ...props }: MyLinkProps<T>,
   ref: PolymorphicRef<T>["ref"]
 ) => {
   return (
     <Polymorphic disabled={isDisabled} ref={ref} as={as || "a"} {...props}>
       MyLink {userName}
     </Polymorphic>
   );
 }
);
// Polymorphic Types

export type PolymorphicAs<T extends React.ElementType> = {
	as?: T;
};

export type PolymorphicRef<T extends React.ElementType> =
	React.ComponentPropsWithRef<T>["ref"];

// Polymorphic Component
type Level3ComponentProps<T extends React.ElementType> = PolymorphicAs<T> &
 PolymorphicRef<T>;

export const Polymorphic = <T extends React.ElementType = "div">({
 as,
 ...props
}: Level3ComponentProps<T>) => {
 const Tag = as || "div";
 return <Tag {...props}>{props.children}</Tag>;
};

 


Conclusion

The Polymorphic component pattern is a pattern that often appears when developing Headless components. While this post focused intensively on tag changes, it's used broadly for integrating with 3rd-party libraries, modifying Styled-component source code, and more. 

While it has the advantage of being intuitive in how 'as' is used, it has the drawback of complex type definitions. 

Since React 19 and later versions are said to improve ref passing as regular props instead of forwardRef, we can cautiously speculate that the difficulty of using the Polymorphic component pattern might decrease somewhat in future React versions. 

Let's master various polymorphic types to achieve highly reusable and safe development!

CNHur HyeonBin (Max)
Reponses  0