Let’s think about a 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>
}
The ProductCard
component has an Image
and three Text
s, 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 |
---|---|---|
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:
You can create separate components for each page, but this will lead to code duplication and violate the DRY principle.
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>;
};
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>
);
};
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>;
};
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>;
};
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>
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>
}
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;
};
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>
);
};
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>;
};
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>
);
};
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)