Anidar componentes

<< Filtros (Pipes) Directivas >>

Antes de ver como se anidan los componentes, es bastante útil comprender como funciona el ciclo de vida de los mismos. Es decir, qué ocurre cuando se crean, se destruyen, o se detectan cambios en los mismos.

Ciclo de vida de los componentes

Hay varias etapas por las que pasa un componente durante su ciclo de vida (creado → destruido). Angular nos permite establecer ciertos comportamientos en cada una de dichas etapas, creando métodos en el componente que se ejecutan cuando se alcanza cada una.

  • ngOnInit → Se ejecuta una vez cuando el componente está listo. Se utiliza para realizar operaciones iniciales como obtener datos del servidor, ya que se recomienda no usar el constructos nada más que para inicializaciones simples de valores. Si el componente tiene parámetros de entrada (@Input), será cuando estén disponibles por primera vez (en el constructor no lo están).
  • ngAfterContentInit → Se ejecuta (después de ngOnInit) una vez se haya incluido el contenido proyectado externamente dentro del componente. Normalmente dentro de elementos ng-content. Si usas los decoradores @ContentChild, o @ContentChildren, será cuando las referencias a dichos elementos estarán disponibles. Más información: Proyección de contenido en componentes
  • ngAfterViewInit → Se ejecuta (después de ngAfterContentInit) una vez justo después de ngOnInit. Se ejecutan cuando cuando la vista (plantilla) del componente y de los componentes hijos están listos para mostrarse por primera vez. Si se usan los decoradores @ViewChild o @ViewChildren en el componente. Este evento indica que ya están disponibles esas referencias a elementos internos de la plantilla para trabajar con ellas.
  • ngOnChanges → Se ejecuta cada vez que una propiedad enlazada a la vista cambia. El método asociado recibe un objeto de tipo SimpleChanges con los nuevos valores y los anteriores. Se ejecuta con bastante frecuencia (aunque depende de la estrategia de detección de cambios elegida), así que cuidado con poner código costoso de computar.
  • ngDoCheck → Este método se utiliza para detectar cambios no detectados automáticamente por Angular. Es decir, hacer comprobaciones extra. Se ejecuta justo después de ngOnChanges, es decir, a menudo.
  • ngAfterContentChecked y ngAfterViewChecked → Se ejecutan después de cada ngDoCheck, en ese orden. Normalmente no se implementan.
  • ngOnDestroy → Se llama una vez cuando el componente se destruye (se elimina del DOM). Utiliza este método si necesitas limpiar algunos datos, o eliminar subscripciones a eventos, observables, etc.

Para implementar estos métodos asociados al ciclo de vida, primero debemos implementar la interfaz correspondiente en el componente, que se llama igual que el método pero sin el prefijo 'ng'. Veamos un ejemplo muy sencillo utilizando ngOnInit:

export class ProductsPageComponent implements OnInit {
  //...
  ngOnInit(): void {
    console.log('El componente products-page ha sido inicializado');
  }
  //...
}

Podrás observar como se imprime el mensaje en la consola al cargar el componente en el DOM.

Nuevas funcionalidades asociadas al ciclo de vida

En los componentes basados en señales (Signals API), los métodos del ciclo de vida no se suelen necesitar, ya que las señales tienen su propia detección de cambio de valores (computed, effect, ...). Además, se han creado nuevas funciones para ejecutar código cuando se produce un ciclo de detección de cambios o cuando el componente se destruye.

Vamos a ver qué deferencias podemos encontrar al trabajar con señales:

  • El método ngOnInit se vuelve innecesario, ya que podemos reaccionar a los cambios con funciones como computed o effect. Además los parámetros de entrada del componente se pueden gestionar como señales, como veremos más adelante.
  • Los métodos AfterViewInit y AfterContentInit son también innecesario si trabajamos con señales en las referencias a los elementos de la plantilla (viewChild y contentChild) como veremos en un futuro. Además tenemos una función nueva llamada afterNextRender que se ejecuta una vez se ha renderizado el DOM por primera vez.
  • Los métodos que gestionan la detección de cambios no serán necesarios por la naturaleza de las señales. Además, tenemos la nueva función afterRender que podemos crear en el constructor y se ejecutaría cada vez que Angular actualiza la vista y renderiza los cambios.
  • Similar a la función effect, tenemos afterRenderEffect, que se ejecutaría cada vez que una seña vinculada cambie, pero en este caso se haría después del último renderizado de cambios, momento en el cual Angular deja manipular el DOM. Ideal si trabajamos con librerías externas que manipulan el DOM, por ejemplo.
  • El método ngOnDestroy generalmente tampoco hará falta, ya que suele utilizarse para cancelar la subscripción a observables, y hay una nueva función llamada takeUntilDestroyed que automatiza el proceso. En el caso de las señales y funciones effect creadas en el componente, Angular las destruye a la vez que el componente. 

Componentes anidados

Los componentes se pueden anidar. Esto implica utilizar el selector de un componente dentro de la plantilla de otro componente. Angular detectará este selector y cargará la plantilla del componente dentro del selector. Es importante saber que antes de utilizar el selector de otro componente en la plantilla, se debe añadir al array de imports del componente padre.

Hay que tener cuidado de no anidar, por ejemplo, un componente B dentro de la plantilla del componente A y viceversa, ya que formaríamos un bucle recursivo generando un error que impediría el funcionamiento de la aplicaicón.

El objetivo es dividir nuestra aplicación en fragmentos o componentes reutilizables, tanto en otras páginas de la misma aplicación, como en otras aplicaciónes. También de esta forma repartirmos el código y la responsabilidad en lugar de centralizar en componentes enormes. Cada componente encapsula su plantilla (HTML), el código que la gestiona y los estilos asociados.

Para nuestro ejemplo de productos, vamos a crear el componente product-item, que representará cada producto que vamos a mostrar en la vista, y star-rating, que usaremos para valorar los productos con una puntuación del 1 al 5. También separaremos el formulario de añadir producto en un componente llamado product-form, que en el futuro situaremos en otra ruta de nuestra aplicación. 

Creando el componente product-item

Primero vamos a crear el componente:

ng g c product-item

Ahora vamos a implementar el componente. La plantilla del mismo tendrá el fragmento correspondiente a un producto (lo que había dentro del @for que recorre el array de productos). Hay que trasladar también el CSS específico del componente padre al componente hijo (si no, no se aplicarán los estilos).

Por ahora usaremos datos estáticos, y en breve veremos como pasar la información del producto del componente padre al hijo. Las propiedades que obtendremos más adelante del componente padre las vamos a crear como signals, ya que desde Angular 18 se pueden gestionar con esta nueva API.

<tr>
  <td>
    @if (showImage()) {
    <img
      [src]="product().imageUrl"
      alt=""
      [title]="product().description | uppercase"
    />
    }
  </td>
  <td>{{ product().description }}</td>
  <td>{{ product().price | currency : "EUR" : "symbol" }}</td>
  <td>{{ product().available | date : "dd/MM/yyyy" }}</td>
  <td><button (click)="deleteProduct()" class="btn btn-danger btn-sm fw-bold">X</button></td>
</tr>

Para que se muestre este nuevo componente, lo importaremos dentro de products-page y lo incluiremos en su plantilla, sustituyendo las filas de la tabla por este nuevo componente anidado.

<!-- ... -->
@for (product of filteredProducts(); track product.id) {
    <product-item />
}
<!-- ... -->

Veremos como se inserta la plantilla del nuevo componente dentro del elemento <product-item>. Sin embargo, el resultado no será el deseado:

El problema, en este caso en particular, es que el navegador espera que el elemento tr esté directamente dentro de tbody, por lo que al crear un nodo extra entre ambos, hace que no reconozca la fila como parte de la tabla. Tenemos 2 posibles soluciones para esto.

Solución 1: Cambiar selector componente

Por defecto el selector del componente implica que debemos crear una etiqueta con ese nombre en la plantilla. Este selector funciona como los selectores CSS, por lo que podríamos cambiar para que reconozca una clase o un atributo en lugar de un elemento (también podríamos hacer un selector más complejo). Angular solo renderiza la plantilla del componente dentro del elemento que cumpla con las condiciones del selector.

En nuestro caso, vamos a indicar que el componente se renderiza dentro de un elemento tr que tenga un atributo llamado product-item (debemos quitar el elemento tr de la plantilla del componente hijo y pasarla al padre):

<td>
    @if (showImage()) {
    <img
        [src]="product().imageUrl"
        alt=""
        [title]="product().description | uppercase"
    />
    }
</td>
<td>{{ product().description }}</td>
<td>{{ product().price | currency : "EUR" : "symbol" }}</td>
<td>{{ product().available | date : "dd/MM/yyyy" }}</td>
<td><button (click)="deleteProduct()" class="btn btn-danger btn-sm fw-bold">X</button></td>

Después adaptamos la plantilla del componente products-page para reflejar el cambio en el selector:

@for (product of filteredProducts(); track product.id) {
    <tr product-item></tr>
}

Esta es una forma de solucionar el problema. Sin embargo la estructura de tabla es muy rígida y permite poca personalización con CSS. Por ejemplo, no podríamos hacerla muy responsive ante cambios de resolución.

Solución 2: Cambiar estructura HTML

Otra opción es usar elementos HTML como div para estructurar el contenido y crear un sistema de filas y columnas como el de una tabla, utilizando flex o grid. En este caso usaremos el sistema de filas y columnas de Bootstrap que internamente utiliza CSS flex.

Así quedaría el componente padre (products-page):

@if (filteredProducts().length) {
  <div class="row headers g-0">
    <div class="col-2">
      @let buttonText = (showImage()?'Hide':'Show') + ' images';
      <button
        class="btn btn-sm"
        [ngClass]="{
                'btn-danger': this.showImage(),
                'btn-primary': !this.showImage(),
              }"
        (click)="toggleImage()"
      >
        {{buttonText}}
      </button>
    </div>
    <div class="col-4 my-auto">Producto</div>
    <div class="col my-auto">Precio</div>
    <div class="col my-auto">Disponible</div>
    <div class="col-auto" style="width: 2em;"></div>
  </div>
  @for (product of filteredProducts(); track product.id) {
    <product-item class="row g-0" />
  }
}

Y así quedaría el componente hijo (product-item):

<div class="col-2 ps-2">
  @if (showImage()) {
    <img
      [src]="product().imageUrl"
      alt=""
      [title]="product().description | uppercase"
    />
  }
</div>
<div class="col-4 my-auto">{{ product().description }}</div>
<div class="col my-auto">{{ product().price | currency : "EUR" : "symbol" }}</div>
<div class="col my-auto">{{ product().available | date : "dd/MM/yyyy" }}</div>
<div class="col-auto me-2 my-auto">
  <button (click)="deleteProduct()" class="btn btn-danger btn-sm fw-bold">X</button>
</div>

Parámetros de entrada (input)

Hemos visto como anidar componentes. Sin embargo, también se ha comprobado que necesitamos obtener datos que están en el componente padre, como el producto a mostrar en el caso de nuestro ejemplo. Para ello Angular ofrece la posibilidad de que un componente padre pueda enviarle ciertos valores a sus componentes hijos.

Previo a la versión 17 de Angular, la forma de recibir un valor del padre era decorar la propiedad con @Input en la clase. Si queríamos acceder al valor inicial teníamos que esperar al menos a la ejecución de ngOnInit en el ciclo de vida del componente (en el constructor no estaban disponibles). Para reaccionar a los cambios podíamos utilizar el método ngOnChanges (ciclo de vida) o utilizar un setter en lugar de un atributo normal.

Desde la versión 17 en adelante, se ha creado una API basada en señales para la comunicación entre componentes padre-hijo. Accedemos a los valores de entrada desde el componente hijo con la función input, que nos genera una señal de solo lectura (similar a la función computed) mediante la cual podremos reaccionar a cambios como ya hemos comprobado utilizando la API de señales. A partir de ahora trabajaremos con esta metodología.

Vamos a ver un ejemplo de como recibiría el componente product-item el producto y el booleano de mostrar imagen utilizando ambas metodologías:

// Angular >= v17
export class ProductItemComponent {
  product = input.required<Product>(); // required (obligatorio)
  showImage = input(true); // Con valor inicial por defecto (opcional)

  //...
}

A partir de ahora vamos a seguir trabajando únicamente con la API de señales por motivos prácticos ya que el ejemplo está destinado entre otras cosas a ser compatible con una detección de cambios zoneless introducida por primera vez en Angular 18.

Si utilizamos input.required, estamos obligando a que el componente padre nos pase el valor o se generará un error. Al no establecer un valor por defecto, debemos tipar el valor recibido utilizando un tipado genérico. En el caso de los input opcionales, conviene establecer un valor por defecto (se puede inferir el tipo a partir de dicho valor).

En el elemento padre le vinculamos los parámetros de entrada en la plantilla, utilizando atributo con el mismo nombre que el parámetro de entrada y generalmente utilizando corchetes para vincular a un valor del componente padre (si no los ponemos, estaríamos pasando un string literal).

@for (product of filteredProducts(); track product.id) {
  <product-item [product]="product" [showImage]="showImage()" class="row g-0" />
}

Con esto ya deberíamos ver los diferentes productos correctamente. Y el botón para ocultar las imágenes también funcionará.

Parámetros de salida (output)

Los parámetros de salida (output) permiten que un componente hijo envíe un evento a un componente padre. Este evento puede informar al componente padre que ha ocurrido algo con el componente anidado, como por ejemplo, que se ha borrado, o también se puede enviar cualquier tipo de dato junto al evento.

Los parámetros de salida se definen a partir de la versión 17 de Angular con la función output, tipando con genéricos el tipo de dato que envían (void para indicar que no envían datos). En versiones anteriores se venía usando (todavía se puede) el decorador @Output asociando al parámetro un objeto del tipo EventEmitter.

Por ejemplo, vamos a crear un parámetro o evento de salida en product-item para informar al componente padre (es quien gestiona el array de productos) que se ha hecho click en el botón de borrar. 

// Angular >= v17
export class ProductItemComponent {
  //...
  deleted = output<void>();

  deleteProduct() {
    this.deleted.emit(); // Lanzamos el evento
  }
}

Como ocurre con los parámetros de entrada, a partir de ahora utilizaremos la API más actual (output) para los ejemplos.

En el componente padre (products-page), escucharemos el evento deleted que nos avisará cuando debemos borrar un producto, llamando al método correspondiente.

@for (product of filteredProducts(); track product.id) {
  <product-item ... (deleted)="deleteProduct(product)" />
}

Creando el componente product-form

Vamos a mover también el formulario de añadir producto a un componente aparte. De esta manera dividimos más la lógica de la aplicación. Y además, en el futuro, cuando veamos como funciona el router de Angular, este componente del formulario lo moveremos a una página diferente de la aplicación.

ng g c product-form

A este componente, movemos el HTML del formulario para añadir un nuevo producto, así como toda la lógica relacionada con dicho formulario. La principal diferencia es que al añadir producto, este se envía al padre (products-page) mediante un ouput llamado add, para que este lo añada a la lista de productos. No olvides importar FormsModule para poder utilizar ngModel.

<div class="card mb-4">
  <div class="card-header bg-success text-white">Add product</div>
  <div class="card-body">
    <form (ngSubmit)="addProduct(productForm)" #productForm="ngForm">
      <!-- Todo exactamente igual -->
    </form>
  </div>
</div>

En el componente products-page insertamos el nuevo componente (previa importación) en la plantilla donde estaba el formulario. Escucharemos el evento add y con $event obtenemos el producto que ha producido el formulario y que debemos añadir a la lista. El componente en general quedará mucho más limpio, limitándose a gestionar el listado de productos.

<product-form (add)="addProduct($event)"></product-form>

<div class="card">
  <!-- resto del HTML de la card con el listado de productos -->
</div>

Parámetros de entrada/salida (model)

Al igual que ocurre con la directiva ngModel, podemos vincular parámetros de entrada y salida en un componente hijo. Es decir, que pueden ser modificados tanto por el componente padre, como por el componente anidado. Estos parámetros se definen con la función model y generan una señal de lectura/escritura.

Vamos a verlo con un ejemplo, creando un componente llamado star-rating para puntuar productos.

Creando el componente star-rating

Creamos el componente: ng g c star-rating

Para representar las estrellas podríamos utilizar los caracteres unicode ☆ y ★. Sin embargo, para este ejemplo, vamos a utilizar los iconos de Font Awesome. Lo primero que haremos será instalar esta dependencia en nuestro proyecto:

npm install @fortawesome/fontawesome-free

Posteriormente, añadiremos el CSS de la librería a nuestra aplicación:

/* You can add global styles to this file, and also import other style files */
@import "bootstrap/dist/css/bootstrap.css";
@import "@fortawesome/fontawesome-free/css/all.min.css";

En el componente star-rating, añadiremos una propiedad que indique una puntuación entre 1 y 5. Será un parámetro de tipo model que recibiremos desde product-item y que podremos cambiarlo desde el propio componente.

En la plantilla generaremos 5 estrellas usando la estructura @for para recorrer un array con los números del 1 al 5. Después comprobaremos si el número de cada estrella es inferior o igual al de la puntuación para ponerla rellena (clase fa-solid), o si es superior para ponerla vacía (clase fa-regular).

<div class="star-container">
  @for (star of [1,2,3,4,5]; track star) {
    <i
      class="fa-star"
      [ngClass]="{ 'fa-solid': star <= rating(), 'fa-regular': star > rating() }"
    ></i>
  }
</div>

Vamos a actualizar el componente product-item para que importe el componente star-rating y lo pondremos en su plantilla, añadiendo una columna con la puntuación, pasándole la propiedad rating como bidireccional (asociada a la puntuación de cada producto).

<!-- resto de campos -->
<div class="col my-auto"><star-rating [(rating)]="product().rating"></star-rating></div>
<div class="col-auto me-2 my-auto">
  <button (click)="deleteProduct()" class="btn btn-danger btn-sm fw-bold">X</button>
</div>

Por último, añadimos la cabecera para la puntuación en el componente principal products-page.

<div class="row headers g-0">
  <!-- Resto de cabeceras -->
  <div class="col my-auto">Puntuación</div>
  <div class="col-auto" style="width: 2em;"></div>
</div>

Actualizando la puntuación del producto

Vamos a hacer que cuando hagamos clic en una estrella se actualice la puntuación elegida. Para ello actualizaremos el valor de la señal rating y automáticamente se actualizará la propiedad rating del producto asociado en el componente product-item

También queremos que cuando pasemos el puntero del ratón sobre una estrella, se establezca una calificación temporal que corresponda a la posición de esa estrella. Creamos una propiedad auxiliar llamada auxRating y la inicializamos con el mismo valor que recibimos del padre. En Angular 19 podríamos utilizar linkedSignal para ello, pero por ahora nos conformaremos con utilizar una función effect, ya que cuando cambie el valor de rating, también queremos actualizar el de auxRating.

Cuando saquemos el ratón del elemento que contiene las estrellas sin haber hecho click, se restaurará también el valor de auxRating:

<div class="star-container" (mouseleave)="auxRating.set(rating())">
  @for (star of [1,2,3,4,5]; track star) {
  <i
    class="fa-star"
    [ngClass]="{ 'fa-solid': star <= auxRating(), 'fa-regular': star > auxRating() }"
    (mouseenter)="auxRating.set(star)" (click)="rating.set(star)"
    aria-hidden="true"
  ></i>
  }
</div>

Como la propiedad rating en el producto no es de tipo señal. Si quisiéramos realizar alguna acción en el componente product-item cuando cambia el valor de la puntuación, podemos pasarle la puntuación solo con corchetes [rating] y automáticamente podremos utilizar (ratingChange) como evento de salida, de forma análoga a un evento tipo output que emite automáticamente al cambiar el valor de la señal de tipo model.

<!-- Este es un ejemplo hipotético, en nuestro ejemplo no lo aplicaremos -->
<div class="col my-auto">
  <star-rating
    [rating]="product.rating"
    (ratingChange)="product().rating = $event"
  />
</div>

<< Filtros (Pipes) Directivas >>