Cómo crear componentes componibles y compound components en Angular

Cuando necesitamos soportar diferentes versiones y casos de uso en nuestra aplicación, y además queremos mantener la flexibilidad ante los cambios, las cosas pueden volverse complejas.
Por ejemplo, imagina que en un caso necesitamos mostrar una lista de países, en otro la bandera o el nombre del país seleccionado. Cuando pensamos en cómo resolver esto con Angular, suelen venirnos a la mente un par de alternativas iniciales.
Opción A: Crear un solo componente que concentre toda la lógica y los casos de uso, utilizando ng-container y la directiva *ngIf. Esto genera un componente sobrecargado con demasiada lógica y demasiadas interacciones en un único lugar.
<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>Opción B: Crear una versión diferente para cada caso y ofrecer una experiencia específica para cada escenario:
<country></country>
<country-with-message></country-with-message>
<country-with-flag></country-with-flag>Como alternativa, podemos utilizar el patrón Compound Component: un componente principal que controla el estado y la interacción con el usuario, y otros componentes secundarios que se encargan de renderizar y reaccionar a los cambios.
¿Qué es un Compound Component?
Es un conjunto de componentes o componentes hijos que trabajan en equipo para ayudarnos a construir un componente complejo. Algunas librerías como Kendo UI utilizan este enfoque, conectando varios componentes dentro de un mismo contexto.
Por ejemplo, en los gráficos de Kendo Charts, componentes como kendo-chart, kendo-chart-title y kendo-chart-series trabajan juntos compartiendo datos, estado y contexto para generar un gráfico completo:
<kendo-chart>
<kendo-chart-title
text="Amazing title"
\></kendo-chart-title>
<kendo-chart-series></kendo-chart-series>
<kendo-charts>Además, este patrón produce un código claro y semántico para otros desarrolladores del equipo, ofreciendo una forma muy intuitiva de consumir los componentes.
Crear un componente básico es fácil, pero construir uno flexible y mantenible requiere hacernos algunas preguntas clave antes de empezar:
- ¿Cómo será la sintaxis del componente al consumirlo?
- ¿El componente emitirá eventos o interactuará con otros componentes?
- ¿El componente compartirá estado?
- ¿Tendrá uno o varios componentes hijos?
Para construir compound components en Angular, nos apoyaremos en características clave del framework como ng-content, el decorador @ContentChild y la inyección de dependencias a nivel de componente.
La lista de países
Para mantener un alcance aislado en nuestro ejemplo, creamos un servicio CountryService que provee la lista de países que utilizaremos en el componente:
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);
}
}El componente Country
Ahora construimos el componente contenedor (CountryComponent). Este componente incluirá un elemento <select> en su HTML para mostrar los países provistos por CountryService, inyectado en el constructor, y utilizará el pipe async para suscribirse al observable:
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) { }
}En la plantilla, usamos el pipe async para suscribirnos al observable countries$ y *ngFor para iterar sobre la lista:
<select>
<option *ngFor="let country of countries$ | async" [value]="country.code">
{{ country.name }}
</option>
</select>Puedes leer más en la documentación oficial sobre *ngFor y el pipe async.
Proyección de contenido (Content Projection)
Nuestro componente de países necesita ser flexible y ofrecer una API simple y semántica para otros desarrolladores, algo como esto:
<country>
<country-flag></country-flag>
<country-selected></country-selected>
</country>Para permitir que el componente acepte contenido desde otros componentes, necesitamos usar proyección de contenido.
La proyección de contenido permite que un componente reciba elementos externos mediante la etiqueta <ng-content> en su plantilla.
Al agregar <ng-content>, nuestro componente puede renderizar y aprovechar el contenido de los componentes anidados:
<select>
<option *ngFor="let country of countries$ | async" [value]="country.code">
{{ country.name }}
</option>
</select>
<ng-content>
</ng-content>Puedes consultar más detalles en la guía oficial sobre Content Projection.
Los componentes hijos
A continuación, creamos los componentes hijos: la bandera y el mensaje. Cada uno tendrá una propiedad @Input() llamada selected que utilizaremos junto con *ngIf para mostrar su contenido:
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-country-flag',
templateUrl: './country-flag.component.html'
})
export class CountryFlagComponent {
@Input() selected!: string;
}El componente CountryFlagComponent renderiza la imagen de la bandera utilizando el servicio de countryflagsapi.com cuando recibe el valor seleccionado:
<div *ngIf="selected">
<img src="https://countryflagsapi.com/png/{{selected}}"/>
</div>El código de CountrySelectedComponent sigue exactamente la misma lógica:
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>Con los componentes hijos creados, el siguiente paso es comunicar el componente principal CountryComponent con sus hijos.
Uso de @ContentChild
Dentro del contexto del componente principal, queremos interactuar con CountrySelectedComponent y CountryFlagComponent.
Podemos usar el decorador @ContentChild para obtener una referencia directa a estos componentes proyectados:
@ContentChild(CountrySelectedComponent) countrySelected!: CountrySelectedComponent;
@ContentChild(CountryFlagComponent) countryFlag!: CountryFlagComponent;Luego, creamos el método selectedCountry y escuchamos el evento change en el <select> para capturar el país seleccionado:
<select #country (change)="selectedCountry(country.value)">El método selectedCountry actualiza la propiedad selected de cada componente hijo para que reaccionen a los cambios:
selectedCountry(select:HTMLSelectElement):void {
this.countrySelected.selected = select.value;
this.countryFlag.countrySelected = select.value;
}Ahora el componente app-country reacciona cuando cambia la selección y actualiza los componentes hijos presentes en su contenido:
<app-country>
<app-country-selected></app-country-selected>
<app-country-flag></app-country-flag>
</app-country>
Conoce más detalles sobre ContentChild en la documentación oficial.
Inyección de dependencias a nivel de componente
Hasta aquí, nuestro componente principal utiliza @ContentChild para cada hijo. Pero, ¿qué ocurre si otro desarrollador quiere usar el componente de bandera dos o tres veces, o agregar un nuevo componente de banner cuando se selecciona el país? Algo como esto:
<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>La documentación oficial de Angular indica lo siguiente sobre @ContentChild:
Se utiliza para obtener el primer elemento o directiva que coincida con el selector en el DOM proyectado. Si el DOM cambia y un nuevo hijo coincide con el selector, la propiedad se actualizará.
Si usamos @ContentChild, solo obtendremos la primera instancia. Además, cada vez que agreguemos un nuevo tipo de componente hijo (como app-banner), tendríamos que modificar CountryComponent para agregar una nueva referencia. Esta solución no escala bien ante cambios futuros.
Refactorización
Para resolver esta limitación, eliminamos las referencias estáticas de @ContentChild. En su lugar, creamos un Subject en CountryComponent que funcionará como bus de comunicación, emitiendo los nuevos valores mediante el método next. El código final queda así:
export class CountryComponent {
countries$ = this.countryService.getCountries();
selected$: Subject<string> = new Subject<string>();
constructor(private countryService: CountryService) { }
changed(value: any) {
this.selected$.next(value);
}
}En los componentes hijos, simplemente inyectamos CountryComponent en el constructor. Así pueden acceder directamente al observable selected$ y suscribirse en la plantilla usando el pipe async para guardar el valor en la variable countryName:
export class CountryFlagComponent {
constructor(public country: CountryComponent) {
}
}Luego, consumimos el estado del componente padre directamente en la plantilla:
*ngIf="country.selected$ |async as countryName"
¡Listo! Ahora todos los componentes hijos reaccionan a los cambios dentro del contexto de CountryComponent. Cualquier nuevo componente que creemos puede consumir el país seleccionado simplemente inyectando el componente padre en su constructor, sin necesidad de modificar el componente principal.
Resumen
En este artículo vimos cómo implementar el patrón Compound Component en Angular mediante inyección de dependencias a nivel de componente. Vimos cómo utilizar la proyección de contenido con ng-content y cómo desacoplar la comunicación entre componentes padres e hijos para lograr una API limpia, intuitiva y flexible.
Puedes revisar el código completo del ejemplo en GitHub: https://github.com/danywalls/compound-components-angular.
Modern frontend development — Angular, React, TypeScript, accessibility, and performance.
Artículos Relacionados
Crear una librería de componentes UI con Angular: errores a evitar para tener éxito
He trabajado en varias empresas creando librerías de UI o frameworks internos para usarlos en múltiples productos, con el sueño de tener una gran interfaz, consistencia y el mismo comportamiento para los usuarios. La idea de una librería de UI ayuda a los desarrolladores a evitar la duplicación...
Operadores clave de RxJS que todo desarrollador de Angular debe conocer
Una guía práctica sobre los operadores esenciales de RxJS en Angular: debounceTime, distinctUntilChanged, filter, switchMap, catchError, map, tap y cómo conectarlos con Signals.
Compartir este artículo
Si esta guía te resultó útil, compártela con tu equipo o con otros desarrolladores.
Software real. Lecciones reales.
Comparto las lecciones que aprendí por las malas, para que puedas evitarlas o estar preparado cuando ocurran.
Únete a 13,800+ desarrolladores y lectores.
Cero spam. Cancela tu suscripción en cualquier momento.