Understanding Compound Components
As requirements multiply and use cases diverge, maintaining flexibility in a component while keeping it manageable becomes increasingly difficult.
Picture this scenario: in one view we need a scrollable list of nations, in another a simple flag icon, and in a third just the name of the currently selected country. Several Angular strategies come to mind when facing such demands.
Option A Consolidate all behavior into a single component, relying on ng-container and the ngIf directive to toggle between presentations. The result is a component bloated with conditional logic and intertwined concerns.
<hello name="{{ name }}"></hello>
<select>
<option>A</option>
<option>B</option>
</select>
<ng-container *ngIf="country">
<h1>The country</h1>
</ng-container>
<ng-container *ngIf="flag">
<h1>The flag</h1>
</ng-container>
Option B: Spin up a dedicated component for every scenario, providing a tailored experience for each distinct situation like:
<country></country>
<country-with-message></country-with-message>
<country-with-flag></country-with-flag>
There is a third path, though: the Compound Component Pattern. Here, a single parent component owns the state and the interaction with the user, while its child components focus purely on presentation and reacting to state updates.
What is a Compound Component?
This pattern involves a set of nested components that cooperate to form a single, cohesive unit. Several UI libraries, such as Kendo UI, adopt this approach by exposing interconnected components that share a common context.
Take Kendo Charts as an example: kendo-chart, kendo-chart-title, and kendo-chart-series all work in tandem, sharing data and context to render a complete visualization.
<kendo-chart>
<kendo-chart-title
text="Amazing title"
\></kendo-chart-title>
<kendo-chart-series></kendo-chart-series>
<kendo-charts>
For other developers, this pattern provides a clear and descriptive API, making the intended usage obvious and reducing friction when integrating the components.
While assembling a basic component is straightforward, crafting one that is both powerful and adaptable requires thoughtful planning. A few key questions should be answered before diving in.
- What should the component's public syntax look like?
- Will it need to emit events or coordinate with sibling components?
- Is shared state required between the parent and its children?
- How many child components will be involved?
To explore and implement compound components, we will rely on Angular features such as NgContent, the ContentChild Decorator, and Component Dependency Injection.
The List Of Countries
To keep the data source decoupled from our components, we will supply the list of countries through a dedicated CountryService.
import { Injectable } from '@angular/core';
import {Observable, of} from "rxjs";
interface country {
name:string;
code: string | null;
}
@Injectable({
providedIn: 'root'
})
export class CountryService {
countries: country[] = [
{name: 'Australia', code: 'AU'},
{name: 'Austria', code: 'AT'},
{name: 'Azerbaijan', code: 'AZ'},
{name: 'Bahamas', code: 'BS'},
]
getCountries(): Observable<country[]> {
return of(this.countries);
}
}
The Country Component
Let us construct the country component as the primary container. It will present a native select element populated from the CountryService, which is injected through the constructor. The async pipe will handle the subscription to the observable data stream.
Below is the complete implementation:
import {Component } from '@angular/core';
import {CountryService} from "../services/country.service";
@Component({
selector: 'app-country',
templateUrl: './country.component.html',
styleUrls: ['./country.component.css']
})
export class CountryComponent {
countries$ = this.countryService.getCountries();
constructor(private countryService: CountryService) { }
}
The async pipe subscribes to the countries$ observable, and the ngFor structure directive iterates over the resulting collection to create the option elements.
<select>
<option *ngFor="let country of countries$ | async" [value]="country.code">
{{ country.name }}
</option>
</select>
For more information, see the official documentation for *ngFor and the async pipe.
Content Projection
For the country component to be genuinely flexible and offer an intuitive API to others, the desired usage might look like this:
<country>
<country-flag></country-flag>
<country-selected></country-selected>
</country>
To achieve this, we must employ content projection, which enables a component to accept and render content defined by its consumers.
Content projection is implemented by placing the ng-content element within the country component's template. This placeholder marks where the projected content from other components will be rendered.
With the ng-content element in place, the country component is prepared to render the nested components and their content.
<select>
<option *ngFor="let country of countries$ | async" [value]="country.code">
{{ country.name }}
</option>
</select>
<ng-content>
</ng-content>
You can find more details in the Angular guide on Content Projection.
The Child Components
Our next step is to build the flag and message components. Each will expose a selected property via an @Input decorator, used in conjunction with ngIf to conditionally display their content.
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-country-flag',
templateUrl: './country-flag.component.html'
})
export class CountryFlagComponent {
@Input() selected!: string;
}
The CountryFlag component fetches and displays the corresponding flag image from countryflagapi.com whenever a selection is made.
<div *ngIf="selected">
<img src="https://countryflagsapi.com/png/{{selected}}"/>
</div>
The CountrySelectedComponent follows an identical pattern.
import { Component, Input, OnInit } from '@angular/core';
@Component({
selector: 'app-country-selected',
templateUrl: './country-selected.component.html'
})
export class CountrySelectedComponent {
@Input() selected!: string;
}
<div *ngIf="selected">
Thanks {{selected}} is a great country!
</div>
With the child components in place, the next challenge is establishing communication between the outer Country Component and its children.
Leveraging @ContentChild
Within the country component, we need to work with our two child components, CountrySelectedComponent and CountryFlagComponent.
The @ContentChild decorator gives us a reference to these components.
@ContentChild(CountrySelectedComponent) countrySelected!: CountrySelectedComponent;
@ContentChild(CountryFlagComponent) countryFlag!: CountryFlagComponent;
We introduce a selectedCountry method along with a change event to capture which country has been chosen.
<select #country (change)="selectedCountry(country.value)">
The selectedCountry method refreshes the selected property on each component and responds to any changes.
selectedCountry(select:HTMLSelectElement):void {
this.countrySelected.selected = select.value;
this.countryFlag.countrySelected = select.value;
}
The country component is now wired up to react when the input value changes, injecting either CountrySelectedComponent or CountryFlagComponent into its template.
<app-country>
<app-country-selected></app-country-selected>
<app-country-flag></app-country-flag>
</app-country>
Find out more about ContentChild
Injecting Components via Dependency Injection
The country component relies on ContentChild for each child component. Yet, consider a scenario where a developer wants to include the flag component multiple times or add a banner component upon selection, for instance:
<app-country>
<app-country-selected></app-country-selected>
<app-country-flag></app-country-flag>
<app-country-flag></app-country-flag>
<app-banner></app-banner>
</app-country>
The official Angular documentation states:
@ContentChild is used to obtain the first element or directive that matches the selector from the content DOM. If the content DOM changes and a new child matches the selector, the property will be updated.
While components do react to changes, any new component like app-banner would require adding a reference in CountryComponent. This approach does not scale well for future additions.
Refactoring the Approach
We eliminate the static ContentChild references, introduce a subject that acts as a communication channel, and emit values through the next method. Here's the resulting code:
export class CountryComponent {
countries$ = this.countryService.getCountries();
selected$: Subject<string> = new Subject<string>();
constructor(private countryService: CountryService) { }
changed(value: any) {
this.selected$.next(value);
}
}
Child components receive the selected$ observable through the constructor and subscribe to it in their templates using the async pipe, assigning the value to a countryName variable.
export class CountryFlagComponent {
constructor(public country: CountryComponent) {
}
}
Now we can reference the country component's state directly in the template:
*ngIf="country.selected$ |async as countryName"
This works seamlessly. All components now respond to changes in the country's context, and any other component can access the selected value from CountryComponent by injecting it into its constructor.
Summary
Throughout this article, we've built the Compound Component Pattern in Angular with dependency injection, utilized Content Projection, and crafted a clean API for our components.
You can view the full code at: https://github.com/danywalls/compound-components-angular.
