Robbert • rocks

read time:

A Clean Approach To Dynamic Component Variants

When making a website, you always encounter different sections like a Hero, TextOnly or Gallery. Sometimes these sections have multiple variants. Most of the time you can handle these variants just by changing the CSS.

But what if the physical layout of the section needs to change while the data coming from the cms stays the same?

The Problem

Recently I had to implement some components that could have many variants that had physical layout changes between them. Adding variants to components almost always causes a lot of if/else statements, which makes the code hard to read and maintain.

In my case the way to determine which variant to use was available globally in the application, but the key to the problem is that the variant is chosen based on a string, this string can be passed as a prop, or it can be available globally.

The Solution

During my research on how to solve this problem, I came across two possible solutions.

The first solution was using hooks, and the second solution was using a Higher Order Components.

I choose to use a HOC because the logic would be spread across the application, and a HOC encapsulates the logic in one place.

So my solution was to create a HOC that would take a map of variants and a default variant and then return a component based on the variant selector string.

type ComponentVariantProps<T> = {
  variant?: string
  data: T
}
 
function createDynamicComponent<T>(variants: Record<string, ComponentVariantProps<T>>, DefaultVariant: ComponentVariantProps<T>) {
  return (props: ComponentVariantProps<T>) => {
    const Variant = props.variant ? variants[props.variant] : undefined;
 
    if (Variant) {
      return <Variant {...props.data} />
    }
 
    return <DefaultVariant {...props.data} />
  };
}

With this HOC I do not have to repeat the logic to select which variant to use. I can just create all the components separately and pass them to the HOC.

An example of how to use the HOC can be seen below:

type HeroSection = {
  title: string
  subtitle: string
  image: string
}
 
const Hero = createDynamicComponent<HeroSection>({
  "hero-with-subtitle": (props) => <section><h1>{props.title}</h1><h2>{props.subtitle}</h2></section>,
  "hero-with-image": (props) => <section>
    <h1>{props.title}</h1>
    <img src={props.image} alt="Hero Image" />
</section>
}, (props) => <section><h1>{props.title}</h1></section>);
 
// Will use the default variant
root.render(<Hero data={{title: "Hello World", subtitle: "This is a subtitle", image: "https://picsum.photos/200/300"}} />);
// Will use the variant "hero-with-subtitle"
root.render(<Hero variant={"hero-with-subtitle"} data={{title: "Hello World", subtitle: "This is a subtitle", image: "https://picsum.photos/200/300"}} />);
// Will use the variant "hero-with-image"
root.render(<Hero variant={"hero-with-image"} data={{title: "Hello World", subtitle: "This is a subtitle", image: "https://picsum.photos/200/300"}} />);

An added benefit is that I can more easily test the different variants if I wanted to. I would only have to focus on that specific variant I want to test. The HOC logic would be tested in the HOC unit test.

Conclusion

In my opinion, this solution offers a nice and clean way to render variants. There is not a lot of repeating code, and all the logic is in one place. I do not know if this is the best solution, but I think it is a good start.

Way to improve this solution even further would be to use the dynamic or lazy functions to lazy load the components only when you need them.

Also, you could add a selector callback to the HOC to make the variant logic more dynamic and more flexible, for example, maybe you have a variant based on two properties of the data.