DEV Community

Cover image for Compound Component pattern in react
Nisal Tharaka
Nisal Tharaka

Posted on

Compound Component pattern in react

Let’s think about a product component.

product component

The code for the above component, without worrying too much about styles, will look like this.

// represents a product
type Item = {
  image: string;
  name: string;
  color: string;
  price: string;
};

const ProductCard = (product : Item) => {
  <View>
    <Image src={product.image} />
    <Text>{product.name}</Text>
    <Text>{product.color}</Text>
    <Text>{product.price}</Text>
  </View>
}
Enter fullscreen mode Exit fullscreen mode

The ProductCard component has an Image and three Texts, wrapped in a View. The styles are left out for simplicity.

Imagine you need to show this Product in different places with some UI tweaks. For instance, if it’s in a favorites list, only the image and name should show up, or if it’s on a poster, show the image, name, and price.

To be more clear,

In a store page: In a favorites list In a poster
store product poster product list product
Image, Name, Color and price are visible Only Image and Name are visible Image, Name and Price are visible

To accomplish this, there are a few options:

  1. You can create separate components for each page, but this will lead to code duplication and violate the DRY principle.

  2. Alternatively, you can pass props and use them to manage visibility, for instance,

type ProductCardProps = {
  product: Item;
  isInStore: boolean;
  isInPost: boolean;
  isInFavorites: boolean;
};

const ProductCard = ({
  product,
  isInStore,
  isInPost,
  isInFavorites,
}: ProductCardProps) => {
  <View>
    <Image src={product.image} />
    <Text>{product.name}</Text>
    {isInStore && <Text>{product.color}</Text>}
    {!isInFavorites && (isInStore || isInPost) && <Text>{product.price}</Text>}
  </View>;
};
Enter fullscreen mode Exit fullscreen mode

In this code, the color and price of a product will be shown based on some rules. If the isInStore prop is true, the color will be shown. If isInFavorites is false, and either isInStore or isInPoster is true, the price will be shown.

With all these condition props and how it’s being rendered, the ProductCard component becomes hard to read and goes against the SRP principle because it now responsible for controlling the visibility/rendering of its sub components. It also goes against the Open-Closed principle because the rendering logic will need to be changed if we want to make changes to the component in the future.

Better solution?

While the above options are great for small components that you’re certain won’t change, there’s a better solution out there! and it is Compound component pattern.

Let's see how the code will look like in this pattern.

// given an Item, renders a Product component with all sub components
const renderAsStoreProduct = (product: Item) => {
  return (
    <ProductCard>
      <ProductCard.Image src={product.image} />
      <ProductCard.Name>{product.name}</ProductCard.Name>
      <ProductCard.Color>{product.color}</ProductCard.Color>
      <ProductCard.Price>{product.price}</ProductCard.Price>
    </ProductCard>
  );
};

// given an Item, renders a Product component with only image and product name
const renderAsFavoriteProduct = (product: Item) => {
  return (
    <ProductCard>
      <ProductCard.Image src={product.image} />
      <ProductCard.Name>{product.name}</ProductCard.Name>
    </ProductCard>
  );
};

// given an Item, renders a Product component with image, name and price
const renderAsPosterProduct = (product: Item) => {
  return (
    <ProductCard>
      <ProductCard.Image src={product.image} />
      <ProductCard.Name>{product.name}</ProductCard.Name>
      <ProductCard.Price>{product.price}</ProductCard.Price>
    </ProductCard>
  );
};
Enter fullscreen mode Exit fullscreen mode

Looks cleaner right? Let's analyse the code. The main component is ProductCard component. ProductCard component has sub components, each representing a part of the Product, such as product Image, name, color and price. We can choose which of these sub components should be rendered, without worrying about the rendering logic. ProductCard is no longer responsible for controlling render logic, and we are not repeating ourselves.

Cool. But how we can implement this? Let's start with ProductCard component.

type ProductCardProps = ViewProps & PropsWithChildren
const ProductCard = ({ children, ...rest }: ProductCardProps) => {
  return <View {...rest}>{children}</View>;
};
Enter fullscreen mode Exit fullscreen mode

This component simply renders its children in a View. ViewProps are passed to the actual View component to enable customizations, such as styles.

Next, let's have a look at how to implement each children of ProductCard.

ProductCard.Image = (imageProps: ImageProps) => {
  return <Image {...imageProps} />;
};

ProductCard.Name = ({ children, ...rest }: TextProps & PropsWithChildren) => {
  return <Text {...rest}>{children}</Text>;
};

ProductCard.Color = ({ children, ...rest }: TextProps & PropsWithChildren) => {
  return <Text {...rest}>{children}</Text>;
};

ProductCard.Price = ({ children, ...rest }: TextProps & PropsWithChildren) => {
  return <Text {...rest}>{children}</Text>;
};
Enter fullscreen mode Exit fullscreen mode

Here, we are assigning each child component as a property to ProductCard component. "What sorcery is this?" you might ask if you are new to JS. Well, functions are just objects in javascript. Therefore, we can add and access properties to functions. Note that property names are capitalized. This is because React requires the component names must start with a capital letter.

If you look closer, it may look like same code is repeated for Name, Color and Price. They all receives TextProps and renders a Text component. Isn't this code repetition? well, no. Remember in this example we are omitting styles for simplicity. in a real scenario, Name would have different styles than Color or Price. With this pattern we can easily customize styles of each child component of ProductCard. For example this is how you would style Name component.

...
<ProductCard>
  <ProductCard.Name 
    numberOfLines={2}
    style={{
      fontSize: 24,
      fontWeight: "bold",
    }}>
    {product.name}
</ProductName>
Enter fullscreen mode Exit fullscreen mode

You can also define default styles inside each child, or following the above example, you can customize how each child component should look like without modifying the actual component. Either way your component is now open for extension but closed for modifications.

The code works fine, but there is a problem. In the current setup, the child components can be used without the parent. For example, nothing stops someone from doing this.

const SomeOtherComponent = () => {
  return (
    <View>
      <ProductCard.Name>
        someone wanted this text to be bold and large, just like product name. So, he used Product.Name component because it has same styling.
      </ProductCard.Name>
    </View>
}
Enter fullscreen mode Exit fullscreen mode

This code isn’t communicating as well as it could be. To fix this, we can use the React context API. There are two main advantages to using context for this. First, we can make sure that child components like Product.Name are rendered inside their parent ProductCard. Second, we can share a value that can be used by all the children of ProductCard.

Let's create the context first.

const productCardContext = createContext<Item | null>(null);

const useProductCardContext = () => {
  const value = useContext(productCardContext);
  if (!value) {
    throw new Error(
      "context value cannot be accessed outside productCardContext.Provider",
    );
  }
  return value;
};
Enter fullscreen mode Exit fullscreen mode

The shared value of productCardContext is some object of type Item, and we are using useProductCardContext custom hook to access this value. The hook ensures it is invoked inside productCardContext. It throws an error if this hook is called outside the context.

Now that we have the context, let's modify our ProductCard parent component and its children components.

Here's the updated ProductCard.

type ProductCardProps = PropsWithChildren & {
  item: Item;
  viewProps: ViewProps;
};

const ProductCard = ({ item, viewProps, children }: ProductCardProps) => {
  return (
    <productCardContext.Provider value={item}>
      <View {...viewProps}>{children}</View>
    </productCardContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

The updated ProductCard component is not just a container for its children, but it also acts as a productContextProvider. It takes an Item object as a shared value in context. This way, all the children rendered inside ProductCard will have access to the context value.

Here are the updated children components of ProductCard.

ProductCard.Image = (imageProps: ImageProps) => {
  const item = useProductCardContext();
  return <Image {...imageProps} src={item.image} />;
};

ProductCard.Name = (textProps: TextProps) => {
  const item = useProductCardContext();
  return <Text {...textProps}>{item.name}</Text>;
};

ProductCard.Color = (textProps: TextProps) => {
  const item = useProductCardContext();
  return <Text {...textProps}>{item.color}</Text>;
};

ProductCard.Price = (textProps: TextProps) => {
  const item = useProductCardContext();
  return <Text {...textProps}>{item.price}</Text>;
};
Enter fullscreen mode Exit fullscreen mode

In each child component, we leverage the useProductCardContext custom hook we crafted earlier to access the item. This hook allows us to access the shared value (item) across all components.

With these updated components now we can use our ProductCard like this.

const renderAsStoreProduct = (product: Item) => {
  return (
    <ProductCard item={product}>
      <ProductCard.Image />
      <ProductCard.Name />
      <ProductCard.Color />
      <ProductCard.Price />
    </ProductCard>
  );
};

const renderAsFavoriteProduct = (product: Item) => {
  return (
    <ProductCard item={product}>
      <ProductCard.Image />
      <ProductCard.Name />
    </ProductCard>
  );
};

const renderAsPosterProduct = (product: Item) => {
  return (
    <ProductCard item={product}>
      <ProductCard.Image />
      <ProductCard.Name />
      <ProductCard.Price />
    </ProductCard>
  );
};
Enter fullscreen mode Exit fullscreen mode

Because an Item is shared via the context, we no longer need to pass props to each child. it makes the code more clean and declarative.

So, next time you need to show the same component in different spots, remember the compound pattern!

summary

The compound component pattern is a fantastic way to manage UI tweaks in React components. It’s like having a main component that’s like the boss, and then you have sub-components for each part of the UI. This way, you can easily change things without having to copy and paste code all over the place. Plus, it’s super easy to read and customize, making it a great choice for anyone who wants to keep their code clean and organized.

Top comments (0)