DEV Community

Tomasz Kula
Tomasz Kula

Posted on

The Most Important Thing to Understand About Component Composition 🚀

The problem

Say you are working on an e-commerce application and are tasked with creating a product page that renders a call to action button to buy awesome Nike shoes.

Right now, the app has a following component tree:

> product-page
>> card
>>> card-content
>>>> cta-button
Enter fullscreen mode Exit fullscreen mode

You want to render "Buy Air Max" as a label of the call to action button.

The issue is the button component is deeply nested in the component tree, and the product model information is available in the top most component.

> product-page <-- Here we know the product name
>> card
>>> card-content
>>>> cta-button  <-- Here we want to render the product name
Enter fullscreen mode Exit fullscreen mode

The Struggle

You might jump in and create the @Input() in each component and pass it down to the button component.

<!-- product-page.component -->
<card [productName]="productName"></card>
Enter fullscreen mode Exit fullscreen mode
<!-- card.component -->
<card-content [productName]="productName"></card-content>
Enter fullscreen mode Exit fullscreen mode
<!-- card-content.component -->
<cta-button [productName]="productName"></cta-button>
Enter fullscreen mode Exit fullscreen mode
<!-- cta-button.component -->
Buy {{ productName }}
Enter fullscreen mode Exit fullscreen mode

It works fine, but there are couple of issues:

  • We are adding @Inputs() to components which don't use them, only passing them down to the child components.
> product-page 
>> card <-- I don't care about the product name
>>> card-content <-- I don't care about the product name
>>>> cta-button  
Enter fullscreen mode Exit fullscreen mode
  • It makes adding additional @Inputs() painful. If button needs more data from the product page, you have to pass it through two other components. This process is sometimes referred to as prop drilling.
> product-page <- We know the product price
>> card <-- I have to add @Input() for price
>>> card-content <-- I have to add @Input() for price
>>>> cta-button <-- I have to add @Input() for price
Enter fullscreen mode Exit fullscreen mode
  • It makes unit testing more difficult as you have to test passing the @Inputs() in each component.

The Solution

You can solve this problem using a different approach. How about we use content projection instead of drilling props with @Inputs()?

Compare the previous solution, with the following:

<!-- product-page.component -->
<card> 
  <card-content>
    <cta-button>Buy {{ productName }}</cta-button>
  </card-content>
</card>
Enter fullscreen mode Exit fullscreen mode
<!-- card.component -->
<ng-content></ng-content>
Enter fullscreen mode Exit fullscreen mode
<!-- card-content.component -->
<ng-content></ng-content>
Enter fullscreen mode Exit fullscreen mode
<!-- cta-button.component -->
<ng-content></ng-content>
Enter fullscreen mode Exit fullscreen mode

This approach has the following benefits:

  • We no longer add @Inputs() to components that don't need them so we avoid prop drilling.
  • Components become more extensible. You can pass as much information to the button component as you'd like, without touching the card components.
  • Because of the previous points, unit testing becomes much easier.

The Benefits

Let's see how we might benefit from this approach.

Say you are now tasked with extending the call to action button with a price tag - "Buy Air Max at $199".

With content projection approach, we only need to make a small change in the product page component:

<!-- product-page.component -->
<card> 
  <card-content>
    <cta-button>Buy {{ productName }} at {{ productPrice }}</cta-button>
  </card-content>
</card>
Enter fullscreen mode Exit fullscreen mode

That's it! See how easy it is? No props drilling, no test changes for the child components, no problem :)

Hope you're having a great one, and I'll see you for more web dev posts in the future 🥳

In Case You Missed it

Top comments (0)