Introduction
A while back, I stumbled across this video which explains fairly well in React how to cleanup and decouple your components, (special thanks to Vincas Stonys for the example and video) and I really wanted to see what is the best way to actually do it in Angular.
So, the actual problem that you face is that whenever designing your own components, you might end up with a solution where component you’re building, knows a lot more about your business logic than you might want it to and the result resembles the example below. Not to say the component doesn’t have some benefits: it’s readable, it allows you to reuse it failry simply and you don’t really need too much to maintain it.
<product-card [product]="product" (addToCart)="addToCart($event)" />
Composition
Taking the example above, we quickly realise we have a problem: our client really wants the same component but with a tweak like showing the rating or not show it, the “Add to cart” button needs to change it’s text or the subtitle needs to be seen below the title instead of showing it above like the default way and here is where we usually add some “smelly code” or “smelly solutions” like the following:
<product-card
[product]="product"
[showRating]="true"
buttonText="Add to cart"
[showSubtitleBelowTheTitle]="true"
(addToCart)="addToCart($event)"
/>
And well… this doesn’t really seem scalable at all!
Composition entered the chat
Let’s think of another possible API and code for it. The one below allows you to combine and use the component as we like. And we are allowed to simply just not add components that we don’t need.
<product-card>
<product-card-image slot="header" [src]="product.img" />
<product-card-title>{{product.title}}</product-card-title>
<product-card-subtitle>{{product.subtitle}}</product-card-subtitle>
<product-card-rating [rating]="4" />
<product-card-pricing [price]="product.price" />
<product-card-button (click)="addToCart(product)" slot="action">Add to cart</product-card-button>
</product-card>
For this architecture, ng-content and slots really comes to the rescue. We just need to add it to the container like the following:
@Component({
selector: 'product-card',
template: `
<div class="product-card">
<ng-content select="[slot='header']" />
<div class="product-card-bottom">
<ng-content />
<div #action class="action" [ngStyle]="{ display: !action.hasChildNodes() ? 'none': 'initial' }">
<ng-content select="[slot='action']" />
</div>
</div>
</div>
`
})
export class ProductCard {}
So, what did we just do? Well, we made the container have 3 slots where you can project the content and all you need it the slot attribute on the element you want to drop in those slots. If you just need the component to exist in the main section, you just add it without the slot attribute. Isn’t that easy enough? Besides, we can just add some other non-related html tags along the way and it also works.
The “Context API”
Those of you reading this article right now are thinking: “sure, that’s easy enough but how do we make the container just have the context of which product we have and we can just reference it from there, like in React?”.
Well… the most powerful system that Angular has, that is quite often misused or misunderstood, and that many people just glance over it is the Dependency Injection system. Let’s see how the API should look before digging into the code.
<product-card [product]="product">
<product-card-image slot="header" />
<product-card-title />
<product-card-subtitle />
<product-card-rating />
<product-card-pricing currency="$" />
<product-card-button (click)="addToCart(product)" slot="action">Add to cart</product-card-button>
</product-card>
Now the worst part is to come: let’s share that context. We can first start by storing our product inside a variable inside a service. I’m going to show it to you with signals and the inject function but you can do it with a BehaviourSubject and injecting everything with the constructor (especially if you have an older Angular version).
import { Injectable, signal, inject, Self, Host, SkipSelf } from '@angular/core';
@Injectable()
export class ProductCardContextService {
public product = signal<Product | null>(null);
setProduct(product: Product){
this.product.set(product);
}
}
@Component({
selector: 'product-card',
providers: [ProductCardContextService] // we provide the service inside the container component and not at the module level
...
})
export class ProductCard {
private productCardContextService = inject(ProductCardContextService, {
self: true // This is to ensure every ProductCard gets the instance of it's own service
});
// You could do this with ngOnChanges as well
@Input({ required: true }) set product(product: Product) {
this.productCardContextService.setProduct(product);
}
// Same injector but with the constructor
constructor(
@Self() private productCardContextService: ProductCardContextService
){}
}
@Component({
selector: 'product-card-image',
template: `
<div class="product-image">
<img [src]="ctx.product().img" [alt]="ctx.product().title" />
</div>
`
})
export class ProductCardImage {
public ctx = inject(ProductCardContextService, {
// This ensures that the instance we get is the one declared inside the container. You cannot use this component outside the product-card component.
host: true,
skipSelf: true
});
// Same injector but with the constructor
constructor(
@Host() @SkipSelf() public ctx: ProductCardContextService
){}
}
Finally, the only thing that I haven’t figured out yet is how to have them as static properties. I imagine you could do something like the example below but if you really need to stack these components together, I find no better way than using NgModule. Either way you do it, just remember and keep in mind, to have all the children selectors with the parent selector as a prefix for a more intuitive approach. Example: product-card and product-card-image.
// You could do it this way
@Component({
standalone: true,
selector: 'product-card',
...
})
export class ProductCard {}
@Component({
standalone: true,
selector: 'product-card-image',
...
})
export class ProductCardImage {}
ProductCard.ProductCardImage = ProductCardImage;
// But I find it cleaner like this:
const components = [ProductCard, ProductCardImage];
@NgModule({
imports: components, // if they are standalone
declarations: components, // if they aren't standalone
exports: components // in both cases
})
export class ProductCardModule {}
Some thoughts
Now we know how to build them, but we still don’t really know when to build them. This approach is usually longer when building. There is absolutely no problem in starting the component with a simple parameter and a listener but this should be in the back of our heads when building the different parts because, whenever we have a lot of flags on the same component, rest assured it’s really easy to miss one or break stuff when refactoring or even when just adding a flag more.
Sometimes, so many small changes enter in conflict with one another and you start having these unusable and unmaintainable components and that’s where this comes in handy so… Take it with a grain of salt because it’s not a universal solution; it’s just another tool in your arsenal.
You could also check the Stackblitz example below:
