🇺🇸 EN
Frontend
·7 min read·↗·🇺🇸 EN

Cómo crear componentes componibles y compound components en Angular

Portada principal del artículo: 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>
dynamic-change.gif
dynamic-change.gif

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"
refactor.gif
refactor.gif

¡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.

Parte de la serie Frontend

Modern frontend development — Angular, React, TypeScript, accessibility, and performance.

Ver serie completa

Artículos Relacionados

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.

User avatar
User avatar
User avatar
User avatar
+13K

Únete a 13,800+ desarrolladores y lectores.

Cero spam. Cancela tu suscripción en cualquier momento.