Módulo 03: Componentes

Objetivos del módulo

  • Identificar las piezas de un componente y entender qué genera el compilador a partir de ellas
  • Configurar @Component: selector, imports, template, styles, host y changeDetection
  • Escribir selectores de elemento y de atributo, y saber por qué un componente debe importar lo que usa
  • Decidir entre plantilla y estilos en línea o en fichero aparte
  • Razonar sobre la encapsulación de estilos: Emulated, None, ShadowDom
  • Dar estilo al elemento anfitrión con :host y sus variantes
  • Recorrer el ciclo de vida de un componente y saber qué gancho usar en cada caso
  • Tocar el DOM en el momento correcto con afterNextRender, afterEveryRender y afterRenderEffect
  • Liberar recursos con DestroyRef y escribir funciones reutilizables que se limpian solas
  • Crear componentes en tiempo de ejecución con NgComponentOutlet, ViewContainerRef y createComponent
  • Aplicar la convención de nombres sin sufijo y organizar los ficheros de una aplicación
  • Diagnosticar los errores más habituales al trabajar con componentes

1. Qué es un componente

Una aplicación Angular es un árbol de componentes. Un componente es una pieza de interfaz autónoma: sabe qué datos maneja, qué HTML dibuja, qué aspecto tiene y cómo responde a lo que hace el usuario. Una barra de navegación es un componente, una tabla de empleados es otro, y cada fila de la tabla puede ser otro más.

Si vienes de HTML y JavaScript sin framework, recordarás el esquema habitual: un index.html enorme, un CSS global y varios ficheros JS que buscan elementos con querySelector. Ahí nada impide que una regla CSS rompa otra pantalla, ni que dos scripts peleen por el mismo elemento. Un componente corta ese enredo porque agrupa en un mismo sitio todo lo que pertenece a una parte de la pantalla, y Angular se ocupa de mantener el HTML al día cuando cambian los datos.

Árbol de componentes de la aplicación EMPRESA: App contiene Cabecera, ListaEmpleados y Pie, y ListaEmpleados contiene varias TarjetaEmpleado, cada una con un Avatar

Figura 1. Una pantalla de EMPRESA vista como árbol: cada caja es un componente, y los datos bajan de padres a hijos.

Anatomía: tres cosas unidas por un decorador

Este es un componente completo. Cabe en un fichero:

// src/app/saludo/saludo.ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-saludo',
  template: `
    <h2>Hola, {{ nombre() }}</h2>
    <button type="button" (click)="cambiar()">Cambiar nombre</button>
  `,
  styles: `
    h2 {
      color: #3949ab;
    }
  `,
})
export class Saludo {
  protected readonly nombre = signal('Ana');

  protected cambiar() {
    this.nombre.set('Luis');
  }
}

Tiene cuatro partes:

  • La clase (Saludo) guarda el estado y el comportamiento. Es TypeScript corriente, como viste en el módulo 02.
  • El decorador @Component le dice a Angular que esa clase no es una clase cualquiera, y aporta la configuración.
  • La plantilla (template) es el HTML, ampliado con la sintaxis de Angular: {{ }} para mostrar valores y (click) para escuchar eventos. La verás a fondo en el módulo 04.
  • Los estilos (styles) son CSS que, por defecto, solo afecta a este componente.

signal crea una señal (signal), un valor que avisa a Angular cuando cambia. Fíjate en que se lee llamándola como una función, nombre(), y se escribe con set. De momento basta con eso: el módulo 05 las explica en profundidad.

Las cuatro partes de un componente: decorador, clase, plantilla y estilos, y cómo el compilador las une en una definición de componente

Figura 2. El compilador de Angular combina decorador, plantilla y estilos en una definición estática dentro de la clase.

📘 Concepto: Un componente es una clase con el decorador @Component. La clase define los datos y la lógica; la plantilla define qué se ve; el selector define cómo se usa desde otra plantilla.

Los miembros que usa la plantilla se marcan protected. Así la plantilla puede leerlos, pero otras clases no. Las entradas (input()) se dejan públicas porque forman parte de la interfaz del componente. Las declaramos readonly para no sustituir por error la señal entera.

Cómo arranca la aplicación

Tres ficheros de un proyecto generado por ng new explican todo el arranque. El primero es el HTML que recibe el navegador, casi vacío:

<!-- src/index.html (resumido) -->
<body>
  <app-root></app-root>
</body>

El segundo es el punto de entrada:

// main.ts del proyecto
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';

bootstrapApplication(App, appConfig)
  .catch((err) => console.error(err));

Y el tercero es el componente raíz, App, cuyo selector es app-root:

// app.ts del proyecto
import { Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';

@Component({
  imports: [RouterOutlet],
  selector: 'app-root',
  styleUrl: './app.css',
  templateUrl: './app.html',
})
export class App {
  protected readonly title = signal('lab-angular');
}

Paso a paso:

  1. El navegador carga index.html y ve <app-root>, una etiqueta que no significa nada para él. Queda como un elemento vacío.
  2. Se ejecuta main.ts, que llama a bootstrapApplication(App, appConfig).
  3. Angular crea el inyector raíz con los proveedores de appConfig (lo verás en el módulo 08).
  4. Lee el selector de App, busca <app-root> en el documento y crea el componente dentro de ese elemento.
  5. Al dibujar la plantilla de App encuentra otros selectores y crea los componentes hijos. Así se construye todo el árbol.

Qué genera el compilador

Los decoradores no se quedan como están: el compilador de Angular (ngc) los lee durante la construcción y añade a la clase un campo estático con instrucciones. Este es un extracto real de lo que produce ng build --configuration development para Saludo:

var Saludo = class _Saludo {
  nombre = signal("Ana", ...);
  cambiar() {
    this.nombre.set("Luis");
  }
  static ɵfac = function Saludo_Factory(__ngFactoryType__) {
    return new (__ngFactoryType__ || _Saludo)();
  };
  static ɵcmp = ɵɵdefineComponent({
    type: _Saludo,
    selectors: [["app-saludo"]],
    decls: 4,
    vars: 1,
    consts: [["type", "button", 3, "click"]],
    template: function Saludo_Template(rf, ctx) {
      if (rf & 1) {
        ɵɵdomElementStart(0, "h2");
        ɵɵtext(1);
        ɵɵdomElementEnd();
        ɵɵdomElementStart(2, "button", 0);
        ɵɵdomListener("click", function Saludo_Template_button_click_2_listener() {
          return ctx.cambiar();
        });
        ɵɵtext(3, "Cambiar nombre");
        ɵɵdomElementEnd();
      }
      if (rf & 2) {
        ɵɵadvance();
        ɵɵtextInterpolate1("Hola, ", ctx.nombre());
      }
    },
    styles: ["h2[_ngcontent-%COMP%] { color: #3949ab; }"]
  });
};

No hace falta memorizarlo. Lo importante es la idea:

  • La plantilla deja de ser texto y se convierte en una función. No hay un intérprete de HTML en el navegador.
  • La función tiene dos modos: el bloque de creación (rf & 1) levanta los elementos una sola vez, y el de actualización (rf & 2) solo refresca lo que puede cambiar. Por eso Angular actualiza {{ nombre() }} sin tocar el botón.
  • Una etiqueta que el compilador no reconoce no se convierte en un componente. Por eso, si olvidas un import, obtienes un error de compilación y no un fallo silencioso en el navegador.

Si quieres consultar los metadatos de un componente desde tu código, existe una función pública que evita tocar esos campos internos, los que empiezan por ɵ:

// src/app/saludo/inspeccion.ts
import { reflectComponentType } from '@angular/core';
import { TarjetaEmpleado } from '../empleados/tarjeta-empleado';

const espejo = reflectComponentType(TarjetaEmpleado);

console.log('selector:', espejo?.selector);
console.log('standalone:', espejo?.isStandalone);
console.log('entradas:', espejo?.inputs.map((e) => e.propName));

La salida real es:

selector: app-tarjeta-empleado
standalone: true
entradas: ['empleado']

Un primer ejemplo con EMPRESA

Vamos a construir una lista de empleados con los datos de la aplicación EMPRESA. Todavía no los pedimos a la API (eso llega en el módulo 12), así que partimos de datos en un fichero. Primero, el tipo y los datos:

// src/app/empleados/empleado.model.ts
export interface Empleado {
  empId: number;
  nombre: string;
  apellidos: string;
  oficio: string;
  salario: number;
  comision: number | null;
  deptId: number;
}
// src/app/empleados/datos-empleados.ts
import { Empleado } from './empleado.model';

export const EMPLEADOS: readonly Empleado[] = [
  { empId: 100, nombre: 'Ana', apellidos: 'García López', oficio: 'DIRECTOR GENERAL', salario: 6500, comision: null, deptId: 10 },
  { empId: 101, nombre: 'Luis', apellidos: 'Martínez Ruiz', oficio: 'DIRECTOR', salario: 4800, comision: null, deptId: 20 },
  { empId: 102, nombre: 'Marta', apellidos: 'Sánchez Gil', oficio: 'DIRECTOR', salario: 5000, comision: null, deptId: 30 },
  { empId: 110, nombre: 'Carmen', apellidos: 'Ruiz Ortega', oficio: 'VENDEDOR', salario: 2200, comision: 500, deptId: 20 },
];

Ahora un componente pequeño que solo sabe dibujar un círculo con unas iniciales. Usa input(), que declara un dato que le llega desde el padre. Es un adelanto del módulo 06; por ahora piensa en ello como un atributo propio de la etiqueta:

// src/app/empleados/avatar.ts
import { Component, input } from '@angular/core';

@Component({
  selector: 'app-avatar',
  template: `{{ iniciales() }}`,
  styles: `
    :host {
      display: inline-grid;
      place-items: center;
      width: 44px;
      height: 44px;
      border-radius: 50%;
      background: #3f51b5;
      color: #fff;
      font-weight: 600;
    }
  `,
})
export class Avatar {
  readonly iniciales = input.required<string>();
}

La tarjeta usa el avatar y un pipe. Esta vez la plantilla y los estilos están en sus propios ficheros, que es lo que genera ng generate component:

// src/app/empleados/tarjeta-empleado.ts
import { TitleCasePipe } from '@angular/common';
import { Component, computed, input } from '@angular/core';
import { Avatar } from './avatar';
import { Empleado } from './empleado.model';

@Component({
  selector: 'app-tarjeta-empleado',
  imports: [Avatar, TitleCasePipe],
  templateUrl: './tarjeta-empleado.html',
  styleUrl: './tarjeta-empleado.css',
})
export class TarjetaEmpleado {
  readonly empleado = input.required<Empleado>();

  protected readonly iniciales = computed(() => {
    const { nombre, apellidos } = this.empleado();
    return nombre[0] + apellidos[0];
  });

  protected readonly nombreCompleto = computed(() => {
    const { nombre, apellidos } = this.empleado();
    return `${nombre} ${apellidos}`;
  });
}
<!-- src/app/empleados/tarjeta-empleado.html -->
<app-avatar [iniciales]="iniciales()" />
<div>
  <h3>{{ nombreCompleto() }}</h3>
  <p class="oficio">{{ empleado().oficio | titlecase }}</p>
  <p class="salario">{{ empleado().salario }} € al mes</p>
</div>
/* src/app/empleados/tarjeta-empleado.css */
:host {
  display: flex;
  gap: 14px;
  align-items: center;
  padding: 14px 18px;
  border: 1px solid #c5cae9;
  border-radius: 10px;
  background: #fff;
}

h3 {
  margin: 0;
  font-size: 1.05rem;
  color: #1a237e;
}

p {
  margin: 2px 0 0;
  color: #37474f;
}

.salario {
  font-weight: 600;
}

computed crea una señal derivada: iniciales se recalcula sola cuando cambia empleado. Y por último, la lista:

// src/app/empleados/lista-empleados.ts
import { Component } from '@angular/core';
import { EMPLEADOS } from './datos-empleados';
import { TarjetaEmpleado } from './tarjeta-empleado';

@Component({
  selector: 'app-lista-empleados',
  imports: [TarjetaEmpleado],
  template: `
    <h2>Empleados ({{ empleados.length }})</h2>
    <div class="rejilla">
      @for (empleado of empleados; track empleado.empId) {
        <app-tarjeta-empleado [empleado]="empleado" />
      }
    </div>
  `,
  styles: `
    h2 {
      color: #1a237e;
    }
    .rejilla {
      display: grid;
      grid-template-columns: repeat(2, 1fr);
      gap: 14px;
    }
  `,
})
export class ListaEmpleados {
  protected readonly empleados = EMPLEADOS;
}

Lista de cuatro tarjetas de empleado con avatar de iniciales, nombre, oficio y salario

Figura 3. Resultado de ListaEmpleados: tres componentes anidados que se reparten el trabajo.

Observa el reparto. Avatar no sabe qué es un empleado. TarjetaEmpleado no sabe de dónde salen los datos. ListaEmpleados no sabe cómo se pinta una tarjeta. Cuando un cambio afecte a una sola de esas decisiones, solo tocarás un fichero.

💡 Consejo: Divide un componente cuando su plantilla empiece a tener partes con sentido propio, o cuando repitas el mismo bloque dos veces. Un buen indicio: si no sabes describir lo que hace sin usar la palabra “y”, probablemente sean dos componentes.


2. El decorador @Component

@Component recibe un objeto de configuración. Esta tabla reúne las propiedades que usarás de verdad:

Propiedad Para qué sirve Se ve en
selector Etiqueta o patrón con el que se usa el componente Sección 3
imports Componentes, directivas y pipes que usa la plantilla Sección 4
template / templateUrl Plantilla en línea o en fichero Sección 5
styles / styleUrl / styleUrls Estilos en línea o en fichero Sección 6
encapsulation Cómo se aíslan los estilos Sección 6
host Atributos, clases y eventos del elemento anfitrión Sección 7
changeDetection Cuándo se revisa este componente Abajo y módulo 09
providers / viewProviders Dependencias propias del componente Módulo 08
hostDirectives Directivas aplicadas al anfitrión Módulo 07
exportAs Nombre para referenciar la instancia desde una plantilla Módulo 04
preserveWhitespaces Conservar los espacios en blanco de la plantilla Sección 5
schemas Permitir etiquetas desconocidas, como web components Sección 4

Lo más llamativo del proyecto generado es lo que no aparece. Verás standalone: true en muchos artículos y en código anterior, pero ya no hace falta: todo componente es standalone salvo que se indique lo contrario. Un componente standalone declara él mismo sus dependencias en imports, sin depender de ningún NgModule. Los NgModule solo los necesitarás para leer código antiguo, y se ven en el módulo 28.

Un componente sin changeDetection usa OnPush 🔄 v22.0

🔄 Cambia en Angular 22.0. En la LTS (Angular 21) un componente sin changeDetection usa la estrategia Default, equivalente a Eager. Si usas la LTS: añade changeDetection: ChangeDetectionStrategy.OnPush a cada componente para tener el mismo comportamiento, que es lo que recomienda este curso.

Cada vez que Angular actualiza la pantalla revisa el árbol de componentes. La pregunta es si debe entrar en todos, o solo en los que tienen motivo. Con OnPush (actualización bajo aviso), un componente se revisa únicamente cuando hay una razón: cambia una entrada, se dispara un evento en su plantilla o cambia una señal que lee. Con Eager (actualización ansiosa) se revisa siempre que se revisa su padre.

Este ejemplo lo demuestra con tres componentes que leen un dato. Los dos primeros leen un objeto normal, que no avisa a nadie de sus cambios:

// src/app/deteccion/marcador.ts
import { signal } from '@angular/core';

export const marcador = { clics: 0 };
export const marcadorSenal = signal(0);
// src/app/deteccion/lectura-defecto.ts
import { Component } from '@angular/core';
import { marcador } from './marcador';

@Component({
  selector: 'app-lectura-defecto',
  template: `<p>Sin indicar estrategia: {{ marcador.clics }}</p>`,
})
export class LecturaDefecto {
  protected readonly marcador = marcador;
}
// src/app/deteccion/lectura-eager.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { marcador } from './marcador';

@Component({
  selector: 'app-lectura-eager',
  changeDetection: ChangeDetectionStrategy.Eager,
  template: `<p>Con Eager: {{ marcador.clics }}</p>`,
})
export class LecturaEager {
  protected readonly marcador = marcador;
}
// src/app/deteccion/lectura-senal.ts
import { Component } from '@angular/core';
import { marcadorSenal } from './marcador';

@Component({
  selector: 'app-lectura-senal',
  template: `<p>Con una señal: {{ marcadorSenal() }}</p>`,
})
export class LecturaSenal {
  protected readonly marcadorSenal = marcadorSenal;
}
// src/app/deteccion/demo-deteccion.ts
import { Component, signal } from '@angular/core';
import { LecturaDefecto } from './lectura-defecto';
import { LecturaEager } from './lectura-eager';
import { LecturaSenal } from './lectura-senal';
import { marcador, marcadorSenal } from './marcador';

@Component({
  selector: 'app-demo-deteccion',
  imports: [LecturaDefecto, LecturaEager, LecturaSenal],
  template: `
    <button type="button" (click)="sumar()">Sumar uno</button>
    <p>Pulsaciones en el padre: {{ pulsaciones() }}</p>
    <app-lectura-defecto />
    <app-lectura-eager />
    <app-lectura-senal />
  `,
})
export class DemoDeteccion {
  protected readonly pulsaciones = signal(0);

  protected sumar() {
    marcador.clics++;
    marcadorSenal.update((n) => n + 1);
    this.pulsaciones.update((n) => n + 1);
  }
}

Tras pulsar tres veces, esto es lo que se ve:

El padre marca 3 pulsaciones. El componente sin estrategia sigue en 0, mientras que el de Eager y el de la señal marcan 3

Figura 4. Angular 22: el componente sin estrategia no se entera del cambio en un objeto normal. El que usa una señal sí.

La conclusión práctica no es añadir Eager para salir del paso, sino guardar en señales todo lo que se muestra. Con señales, un componente funciona igual con cualquier estrategia. Si tu pantalla no se actualiza, antes de nada comprueba que el dato cambiado sea una señal. El módulo 09 explica el mecanismo completo, incluido el trabajo sin Zone.js.


3. El selector

El selector es un selector CSS que le dice a Angular dónde crear el componente. Cuando el compilador recorre una plantilla, comprueba cada elemento contra los selectores de lo que has importado.

Forma Selector Se usa así
Elemento app-tarjeta-empleado <app-tarjeta-empleado />
Atributo [appPeligro] <div appPeligro></div>
Elemento y atributo button[appPeligro] <button appPeligro>
Clase .app-aviso <div class="app-aviso"></div>
Varios app-aviso, [appAviso] Cualquiera de los dos
Negación button:not([disabled]) Un botón sin disabled

La forma de elemento es la normal. No se admiten selectores que dependan de la jerarquía, como div app-x, ni pseudoelementos.

Por qué el guion y el prefijo

Todos los ejemplos empiezan por app-. No es capricho: el estándar HTML reserva los nombres con guion para los elementos personalizados, de modo que ninguna etiqueta oficial futura chocará con los tuyos. El prefijo, además, evita conflictos con bibliotecas de terceros (mat- en Angular Material). Se define en angular.json, con la clave "prefix": "app", y ng generate lo aplica. Un equipo puede usar uno propio, por ejemplo emp- para esta aplicación.

Componentes con selector de atributo

A veces necesitas que el elemento final sea uno nativo. Un <app-boton> que envuelve a un <button> pierde cosas gratis: type, disabled, el submit del formulario, la lectura de los lectores de pantalla. Con un selector de atributo, el <button> sigue siendo el <button> real y el componente solo aporta su plantilla y sus estilos:

// src/app/selectores/boton-peligro.ts
import { Component } from '@angular/core';

@Component({
  selector: 'button[appPeligro]',
  template: `<span aria-hidden="true">⚠</span> <ng-content />`,
  styles: `
    :host {
      padding: 8px 14px;
      border: 0;
      border-radius: 6px;
      background: #c62828;
      color: #fff;
      cursor: pointer;
    }
    :host(:disabled) {
      opacity: 0.5;
      cursor: not-allowed;
    }
  `,
})
export class BotonPeligro {}
// src/app/selectores/demo-selectores.ts
import { Component } from '@angular/core';
import { BotonPeligro } from './boton-peligro';

@Component({
  selector: 'app-demo-selectores',
  imports: [BotonPeligro],
  template: `
    <button appPeligro type="button">Borrar empleado</button>
    <button appPeligro type="button" disabled>Borrar departamento</button>
  `,
})
export class DemoSelectores {}

<ng-content /> indica dónde colocar lo que se escribe entre las etiquetas del componente. Se llama proyección de contenido y se explica en el módulo 06.

⚠️ Importante: Sobre un mismo elemento solo puede haber un componente. Si dos componentes importados casan con el mismo elemento, el compilador da el error NG8023. Para añadir comportamiento a un elemento que ya tiene componente se usan directivas (módulo 07).


4. imports: cada componente declara lo que usa

Una plantilla solo puede usar lo que esté en su lista imports. Hay que importar dos veces, y conviene no confundirlas:

  • El import de TypeScript, arriba del fichero, trae el símbolo al módulo.
  • El imports del decorador lo pone a disposición de la plantilla.
import { Avatar } from './avatar';        // 1. TypeScript: ¿dónde está el símbolo?

@Component({
  imports: [Avatar],                      // 2. Angular: la plantilla puede usarlo
  // ...
})

¿Por qué no basta con el primero? Porque el compilador no quiere recorrer todo el proyecto para adivinar qué es <app-avatar>. La lista hace que cada componente sea autosuficiente, y tiene dos ventajas importantes:

  • Errores tempranos. Una etiqueta desconocida se detecta al compilar.
  • Paquetes más pequeños. El empaquetador puede quitar lo que nadie importa (tree shaking). Si importaras TitleCasePipe en toda la aplicación por si acaso, viajaría siempre.

En imports caben componentes, directivas y pipes. Cosas que no necesitan importarse: @if, @for, @switch y @let, porque forman parte del lenguaje de plantillas, y <ng-content>, <ng-template> y <ng-container>.

Si olvidas un import, esto es lo que dice el compilador (salida real, tras quitar TarjetaEmpleado de imports):

X [ERROR] NG8001: 'app-tarjeta-empleado' is not a known element:
1. If 'app-tarjeta-empleado' is an Angular component, then verify that it is included in the '@Component.imports' of this component.
2. If 'app-tarjeta-empleado' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@Component.schemas' of this component to suppress this message.

    src/app/empleados/lista-empleados.ts:7:4:
      7 │     <app-tarjeta-empleado />
        ╵     ~~~~~~~~~~~~~~~~~~~~~~~~

Si importas algo que no usas, recibes un aviso que no detiene la compilación:

▲ [WARNING] NG8113: All imports are unused

    src/app/empleados/sin-usar.ts:6:2:
      6 │   imports: [TarjetaEmpleado],
        ╵   ~~~~~~~

💡 Consejo: Lee estos errores hasta el final. Angular suele proponer la solución exacta. El aviso de imports sin usar merece atención: un import olvidado engorda el paquete sin que nadie se dé cuenta.

Elementos que Angular no conoce

Si usas un web component de terceros, por ejemplo <mi-grafico>, el compilador lo tratará como un error, porque no sabe que existe. Para indicarle que confíe en ti se añade schemas: [CUSTOM_ELEMENTS_SCHEMA]:

// src/app/selectores/con-web-component.ts
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';

@Component({
  selector: 'app-con-web-component',
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `<mi-grafico titulo="Ventas"></mi-grafico>`,
})
export class ConWebComponent {}

El precio es que ese componente pierde la comprobación de etiquetas con guion: una errata en <app-tarjeta-empelado> pasaría sin aviso. Úsalo solo en el componente que lo necesite, nunca como remedio general para un NG8001.


5. La plantilla: en línea o en fichero

Hay dos maneras de escribirla, y hacen exactamente lo mismo. El compilador acaba convirtiendo ambas en la misma función:

  template en línea templateUrl en fichero
Dónde Dentro del .ts .html aparte
Ventaja Todo a la vista en un solo fichero Separa HTML y TypeScript, mejor resaltado y formato
Cuándo Plantillas de pocas líneas Lo demás
Generación ng g c x --inline-template (-t) Por defecto

Un componente debe tener una u otra. Si no indicas ninguna, el compilador da el error NG2001. La ruta de templateUrl y de styleUrl es relativa al fichero del componente, así que ./tarjeta-empleado.html significa “al lado de este .ts”.

Con la extensión de Angular para VS Code (módulo 01), también las plantillas en línea tienen autocompletado y comprobación de errores. Por eso la longitud es el único criterio que importa.

💡 Consejo: Este curso usa plantillas en línea cuando caben en una pantalla, porque así ves el ejemplo completo de un vistazo. En un proyecto real, si una plantilla pasa de unas 15 o 20 líneas, sácala a un fichero.

Por defecto, el compilador elimina los espacios en blanco sobrantes de la plantilla, lo que reduce un poco el código generado. Si alguna vez dependes de ellos, por ejemplo con white-space: pre, usa preserveWhitespaces: true en ese componente, o &ngsp; para forzar un espacio concreto.

Generar componentes con la CLI

ng generate component empleados/tarjeta-empleado
# abreviado
ng g c empleados/tarjeta-empleado

Crea cuatro ficheros en una carpeta nueva:

CREATE src/app/empleados/tarjeta-empleado/tarjeta-empleado.spec.ts
CREATE src/app/empleados/tarjeta-empleado/tarjeta-empleado.ts
CREATE src/app/empleados/tarjeta-empleado/tarjeta-empleado.css
CREATE src/app/empleados/tarjeta-empleado/tarjeta-empleado.html

Y este es el componente generado:

import { Component } from '@angular/core';

@Component({
  imports: [],
  selector: 'app-tarjeta-empleado',
  styleUrl: './tarjeta-empleado.css',
  templateUrl: './tarjeta-empleado.html',
})
export class TarjetaEmpleado {}

Las opciones más útiles:

Opción Efecto
-t, --inline-template Plantilla dentro del .ts
-s, --inline-style Estilos dentro del .ts
--skip-tests Sin fichero .spec.ts
--flat Sin carpeta propia
--selector=emp-tarjeta Selector personalizado
--style=scss Otro formato de hoja de estilos
--dry-run Muestra qué haría, sin crear nada

El .spec.ts generado crea el componente y comprueba que existe. Es un buen punto de partida para el módulo 26.


6. Estilos y encapsulación

Tienes tres propiedades para asociar CSS a un componente:

styles: `h2 { color: navy; }`,          // en línea
styleUrl: './tarjeta.css',              // un fichero
styleUrls: ['./base.css', './tarjeta.css'], // varios ficheros

Lo interesante no es dónde se escribe, sino a qué afecta. En una página normal, una regla h3 { color: red } llega a todos los h3. En un componente, esa misma regla solo llega a los h3 de su plantilla. Es lo que se llama encapsulación de estilos.

Cómo lo consigue: atributos generados

No hay magia. Angular reescribe tu CSS y añade atributos a los elementos. Este es el HTML real de una tarjeta en el navegador:

<app-tarjeta-empleado _ngcontent-ng-c4271057811="" _nghost-ng-c2556037591="">
  <app-avatar _ngcontent-ng-c2556037591="" _nghost-ng-c4091812238="">AG</app-avatar>
  <div _ngcontent-ng-c2556037591="">
    <h3 _ngcontent-ng-c2556037591="">Ana García López</h3>
    <p _ngcontent-ng-c2556037591="" class="oficio">Director General</p>
    <p _ngcontent-ng-c2556037591="" class="salario">6500 € al mes</p>
  </div>
</app-tarjeta-empleado>

Y esta es la regla que llega al <head> de la página:

[_nghost-ng-c2556037591] {
  display: flex;
  /* ... */
}
h3[_ngcontent-ng-c2556037591] {
  margin: 0;
  font-size: 1.05rem;
  color: #1a237e;
}

Fíjate en cómo funciona:

  • _nghost-... marca el elemento anfitrión (host), la etiqueta del propio componente.
  • _ngcontent-... marca todos los elementos de su plantilla. Los del <app-avatar> hijo llevan otro distinto, de ahí que el CSS de la tarjeta no llegue dentro del avatar.
  • Tu selector h3 pasa a ser h3[_ngcontent-...], de modo que solo casa con los h3 marcados.

Esto tiene una consecuencia práctica: el atributo aumenta la especificidad del selector. Y otra, que confunde al principio: un estilo de componente no alcanza el interior de sus componentes hijos, pero el elemento anfitrión del hijo sí recibe los atributos del padre, porque está escrito en su plantilla. Por eso un padre puede dar margen a <app-avatar>, pero no al <span> de dentro de él.

Comparación de los modos de encapsulación: en Emulated los atributos limitan las reglas, en None se vuelven globales, y en ShadowDom una raíz sombra aísla al componente

Figura 5. Los tres modos habituales de encapsulación.

Los modos de encapsulación

Se eligen con la propiedad encapsulation:

Modo Qué hace Los estilos globales entran Los estilos del componente salen
ViewEncapsulation.Emulated Atributos generados. Es el valor por defecto Sí No
ViewEncapsulation.None Sin aislamiento: los estilos del componente son globales Sí Sí
ViewEncapsulation.ShadowDom Usa el Shadow DOM nativo del navegador No No
ViewEncapsulation.ExperimentalIsolatedShadowDom Como el anterior, pero solo se aplican los estilos del propio componente. Es experimental No No

Este ejemplo los compara. Hay una regla global en styles.css:

/* añadido a src/styles.css */
.nota {
  border-left: 6px solid #ff7043;
  padding-left: 10px;
}

Y tres componentes que dibujan el mismo HTML con distinta encapsulación:

// src/app/estilos/nota-emulada.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-nota-emulada',
  template: `<p class="nota">Emulated: recibe el borde global y su propio azul</p>`,
  styles: `
    .nota {
      color: #1565c0;
    }
  `,
})
export class NotaEmulada {}
// src/app/estilos/nota-global.ts
import { Component, ViewEncapsulation } from '@angular/core';

@Component({
  selector: 'app-nota-global',
  encapsulation: ViewEncapsulation.None,
  template: `<p class="nota">None: su fondo amarillo se escapa a toda la página</p>`,
  styles: `
    .nota {
      background: #fff59d;
    }
  `,
})
export class NotaGlobal {}
// src/app/estilos/nota-sombra.ts
import { Component, ViewEncapsulation } from '@angular/core';

@Component({
  selector: 'app-nota-sombra',
  encapsulation: ViewEncapsulation.ShadowDom,
  template: `<p class="nota">ShadowDom: sin el borde global, con su verde</p>`,
  styles: `
    .nota {
      color: #2e7d32;
    }
  `,
})
export class NotaSombra {}
// src/app/estilos/nota-aislada.ts
import { Component, ViewEncapsulation } from '@angular/core';

@Component({
  selector: 'app-nota-aislada',
  encapsulation: ViewEncapsulation.ExperimentalIsolatedShadowDom,
  template: `<p class="nota">IsolatedShadowDom: solo su propio morado</p>`,
  styles: `
    .nota {
      color: #6a1b9a;
    }
  `,
})
export class NotaAislada {}
// src/app/estilos/demo-encapsulacion.ts
import { Component } from '@angular/core';
import { NotaAislada } from './nota-aislada';
import { NotaEmulada } from './nota-emulada';
import { NotaGlobal } from './nota-global';
import { NotaSombra } from './nota-sombra';

@Component({
  selector: 'app-demo-encapsulacion',
  imports: [NotaAislada, NotaEmulada, NotaGlobal, NotaSombra],
  template: `
    <app-nota-emulada />
    <app-nota-global />
    <app-nota-sombra />
    <app-nota-aislada />
    <p class="nota">Párrafo del padre, que no pidió ningún fondo</p>
  `,
})
export class DemoEncapsulacion {}

Cuatro notas y un párrafo del padre con distintos colores, bordes y fondos según el modo de encapsulación

Figura 6. Resultado real de los cuatro modos.

Lo que se mide en el navegador es esto, y la última fila guarda una sorpresa:

Elemento Borde global Fondo amarillo de None Color propio
Emulated Sí Sí Azul
None Sí Sí, es suyo Ninguno
ShadowDom No Sí Verde
ExperimentalIsolatedShadowDom No No Morado
Párrafo del padre Sí Sí Ninguno

Emulated protege en una sola dirección. Sus reglas no salen, pero las globales entran, y por eso recibe tanto el borde como el fondo.

None contamina todo, hasta al párrafo del padre, que no tiene nada que ver con él.

La sorpresa está en ShadowDom. La regla global de styles.css no entra, pero el fondo amarillo sí. El motivo es que Angular copia dentro de cada raíz sombra los estilos que ha registrado de otros componentes con None. Justo eso es lo que evita ExperimentalIsolatedShadowDom: según la documentación, garantiza que solo se apliquen los estilos del propio componente. Su nombre avisa de que es experimental, tanto en Angular 22 como en la LTS, así que no lo uses en producción sin asumir que puede cambiar.

¿Cuál elegir? Deja Emulated, salvo que tengas un motivo. None tiene sentido en contados casos, como un componente que estiliza contenido generado por una biblioteca, y debe usar nombres de clase muy específicos. ShadowDom encaja en componentes pensados para distribuirse como web components, pero su efecto es grande: tampoco llegan las clases globales, por ejemplo las de Tailwind o Bootstrap. La documentación oficial pide entender bien sus implicaciones antes de activarlo.

⚠️ Importante: Las variables CSS sí atraviesan todas las fronteras, también la del Shadow DOM, porque son propiedades heredadas. Son la forma correcta de que un padre personalice a un hijo. Lo practicarás en el módulo 22.

Estilos globales y presupuestos

No todo tiene que estar en un componente. El fichero src/styles.css se aplica a toda la aplicación, y es buen sitio para el reset, la tipografía y las variables CSS del tema. Una regla práctica: si afecta a la aplicación entera, va a styles.css; si afecta a una pieza, va al componente.

Los estilos de componente también pesan, porque viajan dentro del JavaScript. angular.json fija un presupuesto de construcción para cada uno:

{
  "type": "anyComponentStyle",
  "maximumWarning": "4kB",
  "maximumError": "8kB"
}

Si ng build protesta por este límite, suele ser una señal de que estás repitiendo CSS que debería ser global.


7. :host y el elemento anfitrión

El elemento anfitrión es la etiqueta del componente: el <app-tarjeta-empleado> que escribe el padre. No forma parte de la plantilla del componente, pero le pertenece. Para darle estilo desde dentro se usa el selector especial :host.

⚠️ Importante: Un elemento personalizado es display: inline por defecto, porque el navegador no sabe qué es. Si un componente parece no tener tamaño, si width o margin no hacen nada o si el borde sale raro, casi siempre falta esto: :host { display: block; }.

Selector Significado
:host El propio elemento anfitrión
:host(.destacada) El anfitrión, solo si tiene esa clase
:host(:hover) El anfitrión con el ratón encima
:host-context(.oscuro) El anfitrión, si algún antecesor tiene esa clase
::ng-deep Desactiva el aislamiento para lo que le sigue

La propiedad host

Hasta ahora el padre decidía las clases de la etiqueta. Pero un componente también puede añadirse a sí mismo atributos, clases y eventos desde host, con la misma sintaxis que usarías en una plantilla:

// src/app/estilos/insignia-estado.ts
import { Component, computed, input, signal } from '@angular/core';

@Component({
  selector: 'app-insignia-estado',
  template: `{{ texto() }}`,
  host: {
    role: 'status',
    tabindex: '0',
    '[class.baja]': 'baja()',
    '[class.expandida]': 'expandida()',
    '[attr.aria-expanded]': 'expandida()',
    '(click)': 'alternar()',
    '(keydown.enter)': 'alternar()',
  },
  styles: `
    :host {
      display: inline-block;
      padding: 4px 12px;
      border-radius: 999px;
      background: #c8e6c9;
      color: #1b5e20;
      cursor: pointer;
    }
    :host(.baja) {
      background: #ffcdd2;
      color: #b71c1c;
    }
    :host(.expandida) {
      outline: 2px solid #3f51b5;
    }
    :host-context(.oscuro) {
      background: #263238;
      color: #eceff1;
    }
  `,
})
export class InsigniaEstado {
  readonly baja = input(false);

  protected readonly expandida = signal(false);
  protected readonly texto = computed(() => {
    const estado = this.baja() ? 'De baja' : 'En activo';
    return this.expandida() ? `${estado} (consultado)` : estado;
  });

  protected alternar() {
    this.expandida.update((valor) => !valor);
  }
}
// src/app/estilos/demo-insignias.ts
import { Component } from '@angular/core';
import { InsigniaEstado } from './insignia-estado';

@Component({
  selector: 'app-demo-insignias',
  imports: [InsigniaEstado],
  template: `
    <p><app-insignia-estado /> <app-insignia-estado [baja]="true" /></p>
    <div class="oscuro"><app-insignia-estado /></div>
  `,
  styles: `
    .oscuro {
      padding: 12px;
      background: #455a64;
    }
  `,
})
export class DemoInsignias {}

Tres insignias: una verde En activo (consultado), una roja De baja y una oscura dentro de un panel gris

Figura 7. Las variantes de :host responden a las clases del anfitrión y a las de sus antecesores.

La ventaja de host frente a envolver todo en un <div> es que no añades un nivel de marcado inútil. El padre sigue viendo una sola etiqueta, y el componente controla su propia apariencia. Al leer código antiguo encontrarás @HostBinding y @HostListener. Hacen lo mismo con decoradores, pero hoy se prefiere la propiedad host.

Sobre los dos últimos selectores de la tabla, una advertencia. El navegador ya no considera estándar :host-context(), pero el compilador de Angular lo sigue admitiendo por completo en la encapsulación emulada. En cuanto a ::ng-deep, la documentación dice que el equipo de Angular “desaconseja firmemente” usarlo en código nuevo y que solo se mantiene por compatibilidad. Si necesitas personalizar un hijo, usa variables CSS.


8. Ciclo de vida

Un componente nace cuando Angular lo crea, cambia mientras la aplicación se ejecuta y muere cuando desaparece de la pantalla. En cada etapa, Angular llama a unos métodos con nombres concretos, si los has escrito. Se llaman ganchos del ciclo de vida (lifecycle hooks).

Secuencia de ganchos: constructor, ngOnChanges, ngOnInit, ngDoCheck, ngAfterContentInit, ngAfterContentChecked, ngAfterViewInit, ngAfterViewChecked y ngOnDestroy

Figura 8. Los ganchos en orden. El bloque de revisión se repite cada vez que Angular revisa el componente.

Gancho Cuándo se ejecuta Veces Uso típico
constructor Al crear la instancia 1 inject(), inicializar campos, registrar effect
ngOnChanges Al cambiar una entrada, antes de ngOnInit la primera vez Varias Reaccionar a entradas en código antiguo
ngOnInit Tras recibir las entradas por primera vez 1 Trabajo inicial que necesita entradas
ngDoCheck En cada revisión del componente Muchas Detección propia. Rara vez hace falta
ngAfterContentInit Tras proyectar el contenido 1 Leer contentChild
ngAfterContentChecked Tras revisar el contenido Muchas Muy poco usado
ngAfterViewInit Tras crear la vista y las de los hijos 1 Leer viewChild
ngAfterViewChecked Tras revisar la vista Muchas Muy poco usado
ngOnDestroy Antes de destruirlo 1 Limpieza

Las interfaces (OnInit, OnDestroy…) no son obligatorias, porque Angular busca el método por su nombre. Aun así, escríbelas siempre: si te equivocas y pones ngOnInt, con la interfaz obtienes un error de compilación, y sin ella ese método simplemente nunca se ejecuta.

Verlos en acción

Este laboratorio anota cada gancho. La función anotar aplaza la actualización del panel con queueMicrotask, para no cambiar lo que se ve mientras Angular está dibujando:

// src/app/ciclo/registro-ciclo.ts
import { signal } from '@angular/core';

const lineas: string[] = [];

export const registroCiclo = signal<readonly string[]>([]);

export function anotar(texto: string) {
  console.log(texto);
  lineas.push(texto);
  queueMicrotask(() => registroCiclo.set([...lineas]));
}

export function vaciarRegistro() {
  lineas.length = 0;
  registroCiclo.set([]);
}
// src/app/ciclo/ficha-vida.ts
import {
  AfterContentChecked,
  AfterContentInit,
  AfterViewChecked,
  AfterViewInit,
  Component,
  DoCheck,
  input,
  OnChanges,
  OnDestroy,
  OnInit,
  SimpleChanges,
} from '@angular/core';
import { anotar } from './registro-ciclo';

@Component({
  selector: 'app-ficha-vida',
  template: `<p>Ficha del empleado {{ empleadoId() }}</p>`,
})
export class FichaVida
  implements
    OnChanges,
    OnInit,
    DoCheck,
    AfterContentInit,
    AfterContentChecked,
    AfterViewInit,
    AfterViewChecked,
    OnDestroy
{
  readonly empleadoId = input.required<number>();

  constructor() {
    anotar('constructor');
  }

  ngOnChanges(cambios: SimpleChanges) {
    const cambio = cambios['empleadoId'];
    anotar(`ngOnChanges: ${cambio.previousValue} -> ${cambio.currentValue}`);
  }

  ngOnInit() {
    anotar(`ngOnInit: empleado ${this.empleadoId()}`);
  }

  ngDoCheck() {
    anotar('ngDoCheck');
  }

  ngAfterContentInit() {
    anotar('ngAfterContentInit');
  }

  ngAfterContentChecked() {
    anotar('ngAfterContentChecked');
  }

  ngAfterViewInit() {
    anotar('ngAfterViewInit');
  }

  ngAfterViewChecked() {
    anotar('ngAfterViewChecked');
  }

  ngOnDestroy() {
    anotar('ngOnDestroy');
  }
}

El panel es un componente hermano de la ficha. Esto importa: si el padre leyera la señal del registro, cada anotación revisaría al padre y a la ficha, y cada revisión provocaría otra anotación, en un bucle sin fin.

// src/app/ciclo/panel-registro.ts
import { Component } from '@angular/core';
import { registroCiclo } from './registro-ciclo';

@Component({
  selector: 'app-panel-registro',
  template: `
    <ol>
      @for (linea of registro(); track $index) {
        <li>{{ linea }}</li>
      }
    </ol>
  `,
  styles: `
    :host {
      display: block;
      padding: 6px 12px;
      background: #eceff1;
      border-radius: 8px;
      font: 0.9rem Consolas, monospace;
    }
  `,
})
export class PanelRegistro {
  protected readonly registro = registroCiclo;
}
// src/app/ciclo/demo-ciclo.ts
import { Component, signal } from '@angular/core';
import { FichaVida } from './ficha-vida';
import { PanelRegistro } from './panel-registro';
import { vaciarRegistro } from './registro-ciclo';

@Component({
  selector: 'app-demo-ciclo',
  imports: [FichaVida, PanelRegistro],
  template: `
    <button id="cambiar" type="button" (click)="siguiente()">Cambiar empleado</button>
    <button id="padre" type="button" (click)="revisiones.set(revisiones() + 1)">Revisar el padre</button>
    <button id="alternar" type="button" (click)="visible.set(!visible())">Mostrar u ocultar</button>
    <button id="vaciar" type="button" (click)="vaciar()">Vaciar registro</button>
    <p>Revisiones del padre: {{ revisiones() }}</p>
    @if (visible()) {
      <app-ficha-vida [empleadoId]="empleadoId()" />
    }
    <app-panel-registro />
  `,
})
export class DemoCiclo {
  protected readonly empleadoId = signal(100);
  protected readonly revisiones = signal(0);
  protected readonly visible = signal(true);

  protected siguiente() {
    this.empleadoId.update((id) => id + 1);
  }

  protected vaciar() {
    vaciarRegistro();
  }
}

Al cargar la página, el registro muestra la creación completa:

constructor
ngOnChanges: undefined -> 100
ngOnInit: empleado 100
ngDoCheck
ngAfterContentInit
ngAfterContentChecked
ngAfterViewInit
ngAfterViewChecked

Los botones también provocan una revisión al pulsarlos, así que en las siguientes salidas se ha vaciado antes el registro y solo se muestran las líneas nuevas. Si pulsas Cambiar empleado, la entrada cambia y se repiten los ganchos de revisión:

ngOnChanges: 100 -> 101
ngDoCheck
ngAfterContentChecked
ngAfterViewChecked

Si pulsas Revisar el padre, cambia un dato del padre que la ficha no utiliza:

ngDoCheck
ngAfterContentChecked
ngAfterViewChecked

Y si pulsas Mostrar u ocultar, la ficha se destruye:

ngOnDestroy

Panel con cuatro botones y la lista numerada de ganchos ejecutados al crear la ficha

Figura 9. El laboratorio tras la carga inicial.

Tres enseñanzas de estas salidas:

  • Los ganchos Init solo aparecen en la creación. Los Check, en cada revisión.
  • ngOnChanges también funciona con entradas declaradas con input(), y recibe el valor anterior y el nuevo. La primera vez el anterior es undefined.
  • Revisar al padre ejecuta ngDoCheck en la ficha, aunque nada le afecte y su plantilla no se actualice. Por eso los ganchos ...Check no pueden contener trabajo pesado.

Por qué ngOnInit y no el constructor

Cuando se ejecuta el constructor, Angular aún no ha asignado las entradas. Si lees ahí una entrada obligatoria, el compilador te detiene:

X [ERROR] NG8118: `x` is a required `input` and does not have a value in this context.

El compilador solo ve la lectura directa. Si la lectura queda escondida dentro de un método al que llama el constructor, compila, y el fallo llega en ejecución:

NG0950: Input "x" is required but no value is available yet.

Por eso existe ngOnInit: es el primer momento en que las entradas ya tienen valor. El reparto queda así:

  • En el constructor o en los inicializadores de campo: inject(), señales, computed, effect. Son definiciones, no acciones.
  • En ngOnInit: lo que necesite el valor inicial de las entradas y deba ocurrir una vez.

Menos ganchos gracias a las señales

Muchos usos clásicos de los ganchos han quedado superados. Si los ves en código existente, entenderás qué hacen, pero en código nuevo tienes alternativas mejores:

Antes Ahora Por qué es mejor
ngOnChanges para calcular un valor computed Se actualiza solo y no hay que comprobar qué entrada cambió
ngOnChanges para lanzar un efecto effect Reacciona a cualquier señal, no solo a entradas
ngAfterViewInit para leer el DOM afterNextRender Sección 9
ngAfterViewChecked para ajustar el DOM afterRenderEffect Sección 9
ngOnDestroy para limpiar DestroyRef Sección 10
ngDoCheck para detectar mutaciones Señales con datos inmutables Ya no hay nada que detectar

Esta versión de ngOnChanges y su alternativa hacen lo mismo:

// Antes
ngOnChanges(cambios: SimpleChanges) {
  if (cambios['empleado']) {
    this.iniciales = this.empleado().nombre[0] + this.empleado().apellidos[0];
  }
}

// Ahora
protected readonly iniciales = computed(() => this.empleado().nombre[0] + this.empleado().apellidos[0]);

ngOnInit y ngOnDestroy siguen siendo perfectamente válidos y muy habituales. Lo que conviene evitar son los ganchos ...Checked y ngDoCheck: se ejecutan muchas veces, y cualquier trabajo costoso en ellos se nota en el rendimiento.


9. Después del render: afterNextRender, afterEveryRender y afterRenderEffect

Hay tareas que solo se pueden hacer con el DOM ya dibujado: medir un elemento, dar el foco a un campo, dibujar en un <canvas> o iniciar una biblioteca de terceros. Para eso sirven estas tres funciones, que a diferencia de los ganchos no son métodos, sino funciones que se llaman durante la creación del componente.

Función Cuándo se ejecuta Uso típico
afterNextRender Una vez, tras el siguiente render Iniciar bibliotecas, medir, dar el foco
afterEveryRender Tras cada render de la aplicación Sincronización manual. Poco frecuente
afterRenderEffect Tras el render, y de nuevo cuando cambian las señales que lee Mantener el DOM sincronizado con señales

Las tres tienen dos ventajas sobre ngAfterViewInit:

  • Solo se ejecutan en el navegador, nunca en el servidor. En una aplicación con SSR (módulo 24) no existen window ni document, y este código no llega a fallar porque no se ejecuta allí.
  • Tienen en cuenta la aplicación entera. ngAfterViewInit se dispara cuando termina este componente, pero otro aún puede cambiar el diseño. Estas funciones esperan a que acabe todo el render.

El ejemplo mínimo: dar el foco

// src/app/render/buscador.ts
import { afterNextRender, Component, ElementRef, viewChild } from '@angular/core';

@Component({
  selector: 'app-buscador',
  template: `<input #campo type="search" placeholder="Buscar empleado" />`,
})
export class Buscador {
  private readonly campo = viewChild.required<ElementRef<HTMLInputElement>>('campo');

  constructor() {
    afterNextRender(() => this.campo().nativeElement.focus());
  }
}

viewChild obtiene una referencia a un elemento de la propia plantilla. Lo verás con detalle en el módulo 06. ElementRef envuelve el elemento real del DOM, disponible en nativeElement.

📘 Concepto: El DOM real solo se debe tocar a través de estas funciones y dentro del propio componente. Si puedes conseguirlo con la plantilla, usa la plantilla: [class.activa], [style.width] o @if.

Las fases: leer y escribir sin atascar el navegador

Medir el DOM y modificarlo en cualquier orden es caro. Cuando cambias un estilo y luego lees offsetHeight, el navegador tiene que recalcular el diseño en ese instante. Si tres componentes alternan lecturas y escrituras, se recalcula una y otra vez, y a eso se le llama layout thrashing.

Angular lo resuelve con cuatro fases, que se ejecutan siempre en este orden en toda la aplicación:

Fase Para qué Devuelve a la siguiente
earlyRead Leer el DOM antes de escribir Sí
write Solo escribir Sí
mixedReadWrite Leer y escribir a la vez. Último recurso Sí
read Leer el DOM después de escribir No

Las cuatro fases en orden, earlyRead, write, mixedReadWrite y read, con los valores que pasan de una a otra

Figura 10. Las fases agrupan las lecturas y escrituras de todos los componentes.

Para demostrar que el orden depende de la fase y no de cómo se escribe, este componente las declara desordenadas:

// src/app/render/demo-fases.ts
import { afterEveryRender, afterNextRender, Component, signal } from '@angular/core';

@Component({
  selector: 'app-demo-fases',
  template: `
    <button type="button" (click)="numero.update((n) => n + 1)">Cambiar</button>
    <p>{{ numero() }}</p>
  `,
})
export class DemoFases {
  protected readonly numero = signal(0);

  constructor() {
    afterNextRender({
      read: (valor) => console.log('read recibe:', valor),
      write: (alto) => {
        console.log('write recibe:', alto);
        return alto * 2;
      },
      earlyRead: () => {
        console.log('earlyRead: mido el DOM');
        return 100;
      },
    });

    afterEveryRender({
      read: () => console.log('afterEveryRender: se pintó', this.numero()),
    });

    console.log('constructor terminado');
  }
}

Salida real, tras cargar la página y pulsar Cambiar una vez:

constructor terminado
earlyRead: mido el DOM
write recibe: 100
read recibe: 200
afterEveryRender: se pintó 0
afterEveryRender: se pintó 1

Cada fase recibe el resultado de la anterior. El constructor termina antes de que se ejecute ninguna fase, porque afterNextRender solo registra el trabajo. afterEveryRender se ejecuta en cada render, por lo que debe ser muy ligero.

⚠️ Importante: No cambies el estado que dibuja la pantalla desde afterEveryRender. Un render provocaría otro, en un ciclo sin fin.

afterRenderEffect: un efecto para el DOM

effect (del módulo 05) se ejecuta durante la detección de cambios, y puede que el DOM aún no esté al día. afterRenderEffect combina las dos ideas: sigue las señales que lee, como effect, pero se ejecuta tras el render. Este gráfico en <canvas> se redibuja solo cuando cambian los datos:

// src/app/render/grafico-salarios.ts
import { afterRenderEffect, Component, ElementRef, input, viewChild } from '@angular/core';

export interface Barra {
  etiqueta: string;
  valor: number;
}

@Component({
  selector: 'app-grafico-salarios',
  template: `<canvas #lienzo width="640" height="280" role="img" aria-label="Salario medio por departamento"></canvas>`,
  styles: `
    :host {
      display: block;
    }
  `,
})
export class GraficoSalarios {
  readonly datos = input.required<readonly Barra[]>();

  private readonly lienzo = viewChild.required<ElementRef<HTMLCanvasElement>>('lienzo');

  constructor() {
    afterRenderEffect(() => this.dibujar(this.datos()));
  }

  private dibujar(datos: readonly Barra[]) {
    const canvas = this.lienzo().nativeElement;
    const ctx = canvas.getContext('2d');
    if (!ctx) {
      return;
    }

    const maximo = Math.max(...datos.map((d) => d.valor));
    const anchoBarra = canvas.width / datos.length;
    const altoUtil = canvas.height - 50;

    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.textAlign = 'center';
    ctx.font = '12px Segoe UI, Arial, sans-serif';

    datos.forEach((dato, i) => {
      const alto = (dato.valor / maximo) * altoUtil;
      const x = i * anchoBarra + 10;
      const y = canvas.height - 26 - alto;

      ctx.fillStyle = '#3f51b5';
      ctx.fillRect(x, y, anchoBarra - 20, alto);
      ctx.fillStyle = '#37474f';
      ctx.fillText(String(dato.valor), x + (anchoBarra - 20) / 2, y - 6);
      ctx.fillText(dato.etiqueta, x + (anchoBarra - 20) / 2, canvas.height - 8);
    });
  }
}
// src/app/render/demo-grafico.ts
import { Component, signal } from '@angular/core';
import { Barra, GraficoSalarios } from './grafico-salarios';

@Component({
  selector: 'app-demo-grafico',
  imports: [GraficoSalarios],
  template: `
    <h2>Salario medio por departamento</h2>
    <app-grafico-salarios [datos]="datos()" />
    <button id="subir" type="button" (click)="subir()">Subir un 10 % en Ventas</button>
  `,
  styles: `
    h2 {
      color: #1a237e;
    }
  `,
})
export class DemoGrafico {
  protected readonly datos = signal<readonly Barra[]>([
    { etiqueta: 'Dirección', valor: 6500 },
    { etiqueta: 'Ventas', valor: 2500 },
    { etiqueta: 'Informática', valor: 3133 },
    { etiqueta: 'Contabilidad', valor: 2817 },
    { etiqueta: 'Marketing', valor: 3100 },
    { etiqueta: 'Logística', valor: 2238 },
  ]);

  protected subir() {
    this.datos.update((lista) =>
      lista.map((d) => (d.etiqueta === 'Ventas' ? { ...d, valor: Math.round(d.valor * 1.1) } : d)),
    );
  }
}

Gráfico de barras en canvas con el salario medio de seis departamentos

Figura 11. Un <canvas> no se actualiza con la plantilla. afterRenderEffect lo mantiene sincronizado con la señal, y pulsar el botón lo redibuja.

Para dibujar en un <canvas> hay que leer y escribir a la vez, así que la forma de función única encaja: se ejecuta en la fase mixedReadWrite.

La otra forma recibe las fases por separado. Cada una recibe como señal el valor de la anterior, y solo se vuelve a ejecutar si cambia lo que lee. Este registro de actividad mide la altura del contenido y baja el scroll hasta el final cada vez que llega un mensaje:

// src/app/render/registro-actividad.ts
import { afterRenderEffect, Component, ElementRef, signal, viewChild } from '@angular/core';

@Component({
  selector: 'app-registro-actividad',
  template: `
    <button id="anadir" type="button" (click)="anadir()">Añadir mensaje</button>
    <div #caja class="caja">
      @for (mensaje of mensajes(); track $index) {
        <p>{{ mensaje }}</p>
      }
    </div>
  `,
  styles: `
    .caja {
      height: 120px;
      overflow-y: auto;
      border: 1px solid #c5cae9;
      padding: 0 10px;
    }
  `,
})
export class RegistroActividad {
  protected readonly mensajes = signal(['Pedido 5001 creado', 'Pedido 5002 creado', 'Pedido 5003 creado']);

  private readonly caja = viewChild.required<ElementRef<HTMLElement>>('caja');

  constructor() {
    afterRenderEffect({
      earlyRead: () => {
        this.mensajes(); // dependencia: repetir cuando lleguen mensajes
        return this.caja().nativeElement.scrollHeight;
      },
      write: (alto) => {
        this.caja().nativeElement.scrollTop = alto();
      },
    });
  }

  protected anadir() {
    this.mensajes.update((lista) => [...lista, `Pedido ${5001 + lista.length} creado`]);
  }
}

Cuál usar

Quieres… Usa
Calcular un valor a partir de señales computed
Reaccionar a señales fuera del DOM: guardar en localStorage, registrar effect
Actuar una sola vez con el DOM listo afterNextRender
Mantener el DOM sincronizado con señales afterRenderEffect
Ejecutar algo tras cada render, sin depender de señales afterEveryRender

Fuera del constructor: el error NG0203

Estas funciones necesitan el contexto de inyección, es decir, ejecutarse mientras Angular construye el componente. Si llamas a afterNextRender desde un manejador de clic, obtienes este error:

NG0203: afterNextRender() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`.

La solución es pedir el Injector al crear el componente y pasárselo como opción, tal como explica el módulo 08:

// src/app/render/foco-diferido.ts
import { afterNextRender, Component, ElementRef, inject, Injector, signal, viewChild } from '@angular/core';

@Component({
  selector: 'app-foco-diferido',
  template: `
    <button id="abrir" type="button" (click)="abrir()">Abrir buscador</button>
    @if (abierto()) {
      <input #campo type="search" placeholder="Buscar" />
    }
  `,
})
export class FocoDiferido {
  protected readonly abierto = signal(false);

  private readonly campo = viewChild<ElementRef<HTMLInputElement>>('campo');
  private readonly injector = inject(Injector);

  protected abrir() {
    this.abierto.set(true);
    afterNextRender(() => this.campo()?.nativeElement.focus(), { injector: this.injector });
  }
}

Este caso es muy habitual: el campo no existe hasta que @if lo crea, así que no se le puede dar el foco justo después de set. Hay que esperar al siguiente render.


10. DestroyRef: limpiar al terminar

Un componente que se destruye desaparece de la pantalla, pero no todo lo que puso en marcha muere con él. Un setInterval sigue ejecutándose, un addEventListener sobre window sigue activo y una suscripción sigue viva. Esa memoria no se libera nunca, y es lo que se conoce como fuga de memoria. Si además el temporizador toca el componente muerto, tienes un error difícil de seguir.

ngOnDestroy resuelve el problema, pero DestroyRef lo hace mejor. Es un objeto que representa el final de la vida del componente, y permite registrar la limpieza junto al código que la necesita:

// src/app/limpieza/reloj.ts
import { Component, DestroyRef, inject, signal } from '@angular/core';

@Component({
  selector: 'app-reloj',
  template: `<p>Segundos en esta pantalla: {{ segundos() }}</p>`,
})
export class Reloj {
  protected readonly segundos = signal(0);

  constructor() {
    const id = setInterval(() => this.segundos.update((s) => s + 1), 1000);
    inject(DestroyRef).onDestroy(() => clearInterval(id));
  }
}
  ngOnDestroy DestroyRef.onDestroy
Dónde se escribe Un método único al final de la clase Junto a lo que se limpia
Varias limpiezas Todas en el mismo método Una llamada por cada una
Fuera de la clase No Sí, desde cualquier función en contexto de inyección
Cancelar No Sí: onDestroy devuelve una función para anular el registro
Saber si ya murió No Sí: propiedad destroyed

Funciones reutilizables que se limpian solas

La última fila de la tabla anterior es la clave de DestroyRef. Como puede pedirse desde una función corriente, puedes empaquetar “algo y su limpieza” en una sola llamada:

// src/app/limpieza/usar-intervalo.ts
import { DestroyRef, inject } from '@angular/core';

export function usarIntervalo(milisegundos: number, accion: () => void) {
  const destroyRef = inject(DestroyRef);
  const id = setInterval(accion, milisegundos);

  const cancelarRegistro = destroyRef.onDestroy(() => clearInterval(id));

  // Devuelve una función para parar antes de tiempo
  return () => {
    clearInterval(id);
    cancelarRegistro();
  };
}
// src/app/limpieza/cuenta-atras.ts
import { Component, signal } from '@angular/core';
import { usarIntervalo } from './usar-intervalo';

@Component({
  selector: 'app-cuenta-atras',
  template: `
    <p>La sesión caduca en {{ restantes() }} s</p>
    <button type="button" (click)="parar()">Parar</button>
  `,
})
export class CuentaAtras {
  protected readonly restantes = signal(60);

  protected readonly parar = usarIntervalo(1000, () => this.restantes.update((s) => s - 1));
}

El componente no sabe nada de temporizadores: pide un intervalo y se olvida. La función funciona en cualquier componente, directiva o servicio, porque inject(DestroyRef) devuelve el del dueño actual. Es la misma técnica de los composables de Vue y de los hooks de React. Observa también que DestroyRef se pide al llamar a usarIntervalo, y por tanto esa llamada debe hacerse en el contexto de inyección: en un campo o en el constructor.

Para saber si el componente sigue vivo tras una operación asíncrona, usa destroyed:

// src/app/limpieza/carga-lenta.ts
import { Component, DestroyRef, inject, signal } from '@angular/core';

@Component({
  selector: 'app-carga-lenta',
  template: `<p>{{ estado() }}</p>`,
})
export class CargaLenta {
  private readonly destroyRef = inject(DestroyRef);

  protected readonly estado = signal('Cargando...');

  constructor() {
    this.cargar();
  }

  private async cargar() {
    await new Promise((resolver) => setTimeout(resolver, 500));

    if (this.destroyRef.destroyed) {
      return; // El usuario ya se fue: no hay nada que actualizar
    }
    this.estado.set('Datos listos');
  }
}

Llamar a onDestroy sobre un componente ya destruido no se ignora en silencio:

NG0911: View has already been destroyed.

Para las suscripciones de RxJS existe un atajo construido sobre DestroyRef, takeUntilDestroyed, que verás en el módulo 15.

💡 Consejo: Cada vez que escribas setInterval, setTimeout largo, addEventListener sobre window o document, ResizeObserver, IntersectionObserver o WebSocket, pregúntate qué lo detiene. Los eventos de la plantilla, como (click), no necesitan limpieza: de ellos se encarga Angular.


11. Componentes dinámicos

Normalmente sabes al escribir la plantilla qué componentes hay en cada sitio. Pero a veces la decisión llega en ejecución: un panel configurable, una lista de avisos que aparecen y desaparecen, un diálogo que se abre desde el código.

Antes de usar estas herramientas, comprueba si te hace falta. Si las opciones son pocas y se conocen, @if o @switch son más sencillos y más seguros:

@switch (vista()) {
  @case ('tarjetas') { <app-vista-tarjetas /> }
  @case ('tabla') { <app-vista-tabla /> }
}

Para retrasar la carga de un trozo de pantalla, lo apropiado es @defer, del módulo 23. Lo que sigue es para cuando el componente es un dato.

Tres formas de crear componentes dinámicos: NgComponentOutlet en la plantilla, ViewContainerRef desde el código y createComponent fuera del árbol

Figura 12. Las tres herramientas, de más declarativa a más manual.

NgComponentOutlet: el componente como dato

Una clase de componente es un valor más de JavaScript, y se puede guardar en una señal. NgComponentOutlet lo dibuja en la plantilla. Este panel de informes describe sus pestañas en un array:

// src/app/dinamicos/pestana-plantilla.ts
import { Component, input } from '@angular/core';

@Component({
  selector: 'app-pestana-plantilla',
  template: `<p>Plantilla de {{ departamento() }}: {{ personas() }} personas</p>`,
})
export class PestanaPlantilla {
  readonly departamento = input.required<string>();
  readonly personas = input.required<number>();
}
// src/app/dinamicos/pestana-gasto.ts
import { Component, computed, input } from '@angular/core';

@Component({
  selector: 'app-pestana-gasto',
  template: `<p>Gasto anual en salarios: {{ anual() }} €</p>`,
})
export class PestanaGasto {
  readonly mensual = input.required<number>();

  protected readonly anual = computed(() => this.mensual() * 12);
}
// src/app/dinamicos/panel-informes.ts
import { NgComponentOutlet } from '@angular/common';
import { Component, signal, Type } from '@angular/core';
import { PestanaGasto } from './pestana-gasto';
import { PestanaPlantilla } from './pestana-plantilla';

interface Pestana {
  etiqueta: string;
  componente: Type<unknown>;
  entradas: Record<string, unknown>;
}

@Component({
  selector: 'app-panel-informes',
  imports: [NgComponentOutlet],
  template: `
    <nav>
      @for (pestana of pestanas; track pestana.etiqueta) {
        <button
          type="button"
          [class.activa]="pestana === actual()"
          (click)="actual.set(pestana)"
        >
          {{ pestana.etiqueta }}
        </button>
      }
    </nav>
    <section>
      <ng-container *ngComponentOutlet="actual().componente; inputs: actual().entradas" />
    </section>
  `,
  styles: `
    nav {
      display: flex;
      gap: 4px;
    }
    button {
      padding: 8px 16px;
      border: 1px solid #c5cae9;
      border-bottom: 0;
      border-radius: 8px 8px 0 0;
      background: #e8eaf6;
      cursor: pointer;
    }
    .activa {
      background: #3f51b5;
      color: #fff;
    }
    section {
      padding: 12px 16px;
      border: 1px solid #c5cae9;
    }
  `,
})
export class PanelInformes {
  protected readonly pestanas: readonly Pestana[] = [
    {
      etiqueta: 'Plantilla',
      componente: PestanaPlantilla,
      entradas: { departamento: 'VENTAS', personas: 6 },
    },
    {
      etiqueta: 'Gasto',
      componente: PestanaGasto,
      entradas: { mensual: 15000 },
    },
  ];

  protected readonly actual = signal(this.pestanas[0]);
}

Panel con dos pestañas, Plantilla y Gasto, con la segunda activa y su contenido debajo

Figura 13. Añadir un informe es añadir un objeto al array. La plantilla no cambia.

El asterisco de *ngComponentOutlet es la sintaxis de las directivas estructurales (módulo 07). Con esta herramienta, al cambiar de componente Angular destruye el anterior y crea el nuevo. Fíjate en que el precio de la flexibilidad es el tipado: entradas es un Record<string, unknown>, así que una errata en el nombre de una entrada no se detecta al compilar. Tampoco detiene la aplicación: solo deja este error en la consola, en este caso para un componente Hijo:

NG0303: Can't set value of the 'noexiste' input on the 'Hijo' component. Make sure that the 'noexiste' property is declared as an input using the input() or model() function or the @Input() decorator.

ViewContainerRef: crear desde el código

ViewContainerRef representa un punto de la plantilla donde se pueden insertar componentes. Lo pides con viewChild, indicando read. Primero, un aviso normal, con una entrada y una salida (output):

// src/app/dinamicos/aviso.ts
import { Component, input, output } from '@angular/core';

export type TipoAviso = 'ok' | 'error';

@Component({
  selector: 'app-aviso',
  template: `
    <span>{{ mensaje() }}</span>
    <button type="button" aria-label="Cerrar" (click)="cerrar.emit()">×</button>
  `,
  host: {
    role: 'status',
    '[class.error]': "tipo() === 'error'",
  },
  styles: `
    :host {
      display: flex;
      justify-content: space-between;
      gap: 16px;
      padding: 10px 14px;
      margin-bottom: 8px;
      border-radius: 8px;
      background: #c8e6c9;
      color: #1b5e20;
    }
    :host(.error) {
      background: #ffcdd2;
      color: #b71c1c;
    }
    button {
      border: 0;
      background: none;
      color: inherit;
      font-size: 1.1rem;
      cursor: pointer;
    }
  `,
})
export class Aviso {
  readonly mensaje = input.required<string>();
  readonly tipo = input<TipoAviso>('ok');
  readonly cerrar = output<void>();
}

Y la zona que los crea:

// src/app/dinamicos/zona-avisos.ts
import {
  Component,
  ComponentRef,
  inputBinding,
  outputBinding,
  signal,
  viewChild,
  ViewContainerRef,
} from '@angular/core';
import { Aviso, TipoAviso } from './aviso';

@Component({
  selector: 'app-zona-avisos',
  template: `
    <button id="ok" type="button" (click)="avisar('Empleado 110 guardado', 'ok')">Guardar</button>
    <button id="mal" type="button" (click)="avisar('No se puede borrar el departamento 20', 'error')">Borrar</button>
    <button id="todos" type="button" (click)="limpiar()">Cerrar todos</button>
    <p>Avisos abiertos: {{ abiertos() }}</p>
    <ng-container #ancla />
  `,
})
export class ZonaAvisos {
  private readonly ancla = viewChild.required('ancla', { read: ViewContainerRef });

  protected readonly abiertos = signal(0);

  protected avisar(mensaje: string, tipo: TipoAviso) {
    const ref: ComponentRef<Aviso> = this.ancla().createComponent(Aviso, {
      bindings: [
        inputBinding('mensaje', () => mensaje),
        inputBinding('tipo', () => tipo),
        outputBinding('cerrar', () => ref.destroy()),
      ],
    });

    this.abiertos.update((n) => n + 1);
    ref.onDestroy(() => this.abiertos.update((n) => n - 1));
  }

  protected limpiar() {
    this.ancla().clear();
  }
}

Dos avisos apilados, uno verde y otro rojo, con un contador de avisos abiertos

Figura 14. Cada aviso es un componente creado desde el código, que se destruye a sí mismo al cerrarse.

Conviene detenerse en estas piezas:

  • createComponent crea e inserta el componente. El resultado, ComponentRef, es el mando a distancia: instance, setInput, destroy, onDestroy.
  • bindings conecta entradas y salidas desde el principio, igual que [mensaje]="..." y (cerrar)="..." en una plantilla. La función que recibe inputBinding se evalúa de forma reactiva: si lee una señal, la entrada se actualiza sola. También puedes pasar la propia señal.
  • Hay un tercer tipo, twoWayBinding, para los enlaces de ida y vuelta con model().
  • clear() destruye todo lo que hay en el contenedor.

La referencia ref lleva el tipo escrito a mano porque se usa dentro de su propia inicialización: sin él, TypeScript no puede deducirlo.

💡 Consejo: Antes de bindings se escribía ref.setInput('mensaje', valor) y ref.instance.cerrar.subscribe(...). Lo seguirás viendo, y setInput sigue siendo útil para cambiar una entrada más tarde. Evita asignar ref.instance.mensaje = ...: las entradas con input() son señales de solo lectura, y además Angular no se enteraría.

Este ejemplo usa twoWayBinding. El editor modifica un model(), y el padre comparte la misma señal:

// src/app/dinamicos/editor-salario.ts
import { Component, model } from '@angular/core';

@Component({
  selector: 'app-editor-salario',
  template: `
    <button id="subir-salario" type="button" (click)="salario.set(salario() + 100)">
      Subir 100 € ({{ salario() }})
    </button>
  `,
})
export class EditorSalario {
  readonly salario = model.required<number>();
}
// src/app/dinamicos/demo-doble.ts
import { afterNextRender, Component, signal, twoWayBinding, viewChild, ViewContainerRef } from '@angular/core';
import { EditorSalario } from './editor-salario';

@Component({
  selector: 'app-demo-doble',
  template: `
    <p id="visto-padre">El padre ve: {{ salario() }} €</p>
    <ng-container #ancla />
  `,
})
export class DemoDoble {
  private readonly ancla = viewChild.required('ancla', { read: ViewContainerRef });

  protected readonly salario = signal(2200);

  constructor() {
    afterNextRender(() => {
      this.ancla().createComponent(EditorSalario, {
        bindings: [twoWayBinding('salario', this.salario)],
      });
    });
  }
}

Cargar el componente solo cuando se necesita

Un import() dinámico trae el código en un fichero aparte en el momento de usarlo. Combinado con ViewContainerRef, el componente no pesa en la carga inicial:

// src/app/dinamicos/informe-pesado.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-informe-pesado',
  template: `<p id="informe">Informe anual de pedidos cargado</p>`,
})
export class InformePesado {}
// src/app/dinamicos/carga-diferida.ts
import { Component, viewChild, ViewContainerRef } from '@angular/core';

@Component({
  selector: 'app-carga-diferida',
  template: `
    <button id="cargar" type="button" (click)="cargar()">Ver informe anual</button>
    <ng-container #ancla />
  `,
})
export class CargaDiferida {
  private readonly ancla = viewChild.required('ancla', { read: ViewContainerRef });

  protected async cargar() {
    const { InformePesado } = await import('./informe-pesado');
    this.ancla().clear();
    this.ancla().createComponent(InformePesado);
  }
}

En la salida de ng build, informe-pesado aparece como un fragmento perezoso (lazy chunk), separado del paquete principal. Para este caso concreto, @defer suele ser más cómodo; import() es útil cuando la clase del componente se decide en ejecución.

createComponent: fuera del árbol

ViewContainerRef inserta en un punto de una plantilla. Un diálogo, en cambio, conviene colocarlo directamente en <body>, para que ningún overflow o z-index de sus antecesores lo recorte. Para eso sirve la función createComponent de @angular/core, que crea un componente fuera del árbol.

Como no tiene padre, hay que entregarle el EnvironmentInjector, y registrarlo en ApplicationRef con attachView, porque en caso contrario Angular nunca lo revisaría:

// src/app/dinamicos/dialogo-confirmacion.ts
import { Component, input, output } from '@angular/core';

@Component({
  selector: 'app-dialogo-confirmacion',
  template: `
    <div class="fondo">
      <div class="dialogo" role="alertdialog" aria-modal="true">
        <p>{{ pregunta() }}</p>
        <button id="si" type="button" (click)="respuesta.emit(true)">Sí</button>
        <button id="no" type="button" (click)="respuesta.emit(false)">No</button>
      </div>
    </div>
  `,
  styles: `
    .fondo {
      position: fixed;
      inset: 0;
      display: grid;
      place-items: center;
      background: rgb(0 0 0 / 40%);
    }
    .dialogo {
      padding: 20px 28px;
      border-radius: 10px;
      background: #fff;
    }
  `,
})
export class DialogoConfirmacion {
  readonly pregunta = input.required<string>();
  readonly respuesta = output<boolean>();
}
// src/app/dinamicos/abrir-confirmacion.ts
import {
  ApplicationRef,
  ComponentRef,
  createComponent,
  EnvironmentInjector,
  inputBinding,
  outputBinding,
} from '@angular/core';
import { DialogoConfirmacion } from './dialogo-confirmacion';

export function abrirConfirmacion(
  appRef: ApplicationRef,
  injector: EnvironmentInjector,
  pregunta: string,
): Promise<boolean> {
  return new Promise((resolver) => {
    const anfitrion = document.createElement('div');
    document.body.append(anfitrion);

    const ref: ComponentRef<DialogoConfirmacion> = createComponent(DialogoConfirmacion, {
      environmentInjector: injector,
      hostElement: anfitrion,
      bindings: [
        inputBinding('pregunta', () => pregunta),
        outputBinding<boolean>('respuesta', (valor) => {
          ref.destroy();
          resolver(valor);
        }),
      ],
    });

    appRef.attachView(ref.hostView);
  });
}
// src/app/dinamicos/demo-dialogo.ts
import { ApplicationRef, Component, EnvironmentInjector, inject, signal } from '@angular/core';
import { abrirConfirmacion } from './abrir-confirmacion';

@Component({
  selector: 'app-demo-dialogo',
  template: `
    <button id="borrar" type="button" (click)="borrar()">Borrar empleado 110</button>
    <p id="resultado">{{ resultado() }}</p>
  `,
})
export class DemoDialogo {
  private readonly appRef = inject(ApplicationRef);
  private readonly injector = inject(EnvironmentInjector);

  protected readonly resultado = signal('Sin decidir');

  protected async borrar() {
    const confirmado = await abrirConfirmacion(this.appRef, this.injector, '¿Borrar a Carmen Ruiz?');
    this.resultado.set(confirmado ? 'Borrado' : 'Cancelado');
  }
}

await abrirConfirmacion(...) se lee como si fuera confirm(), con la ventaja de que el diálogo es tuyo. En una aplicación real lo meterías en un servicio, y para algo serio usarías el Dialog del CDK (módulo 20), que resuelve el foco y la accesibilidad. Como comprobación, al destruir el componente Angular retira el <div> anfitrión de <body>: no queda ningún resto en el documento.

ComponentFactoryResolver ya no existe 🔄 v22.0

🔄 Cambia en Angular 22.0. Se han eliminado ComponentFactory, ComponentFactoryResolver y ModuleWithComponentFactories. En la LTS (Angular 21) todavía existen, pero están obsoletos. Si usas la LTS: no los uses en código nuevo y pasa la clase directamente a viewContainerRef.createComponent(MiComponente), que funciona igual en las dos versiones.

En tutoriales antiguos verás este patrón, que en Angular 22 ya no compila:

// Código antiguo: no compila en Angular 22
const factoria = this.resolver.resolveComponentFactory(Aviso);
const ref = this.contenedor.createComponent(factoria);

La sustitución es directa, porque ahora se pasa la clase:

const ref = this.contenedor.createComponent(Aviso);

12. Nombres y organización

Sin sufijo

Durante años, la guía de estilo pedía sufijos: tarjeta-empleado.component.ts con la clase TarjetaEmpleadoComponent. La guía actual los ha retirado, y ng generate ya no los añade:

  Convención antigua Convención actual
Fichero de clase tarjeta-empleado.component.ts tarjeta-empleado.ts
Plantilla tarjeta-empleado.component.html tarjeta-empleado.html
Estilos tarjeta-empleado.component.css tarjeta-empleado.css
Clase TarjetaEmpleadoComponent TarjetaEmpleado
Componente raíz AppComponent en app.component.ts App en app.ts
Selector app-tarjeta-empleado app-tarjeta-empleado

El cambio ya estaba en la LTS, así que no lleva marca de versión: Angular 21.2 genera exactamente los mismos nombres. Lo comprobarás al abrir cualquier proyecto creado por una versión reciente. Los proyectos actualizados desde versiones antiguas conservan sus sufijos, y no hace falta renombrar nada: es una convención, no un requisito. El compilador solo mira el decorador.

La idea de fondo es que el nombre diga lo que la clase hace: TarjetaEmpleado, ListaEmpleados, ZonaAvisos. Sin sufijo, un nombre mal elegido se nota. El caso típico es el choque entre componente y modelo:

// Mal: ¿Empleado es el dato o la pantalla?
import { Empleado } from './empleado';          // componente
import { Empleado } from './empleado.model';    // interfaz: choque de nombres

La solución es que el componente nombre su función: FichaEmpleado, TarjetaEmpleado, DetalleEmpleado. La interfaz se queda con el sustantivo, Empleado.

Si tu equipo prefiere seguir usando sufijos, se puede configurar en angular.json:

{
  "schematics": {
    "@schematics/angular:component": {
      "type": "component"
    }
  }
}

Con esa configuración, ng g c tarjeta genera tarjeta.component.ts, tarjeta.component.html y tarjeta.component.css, con la clase TarjetaComponent.

Cómo organizar las carpetas

Agrupa los ficheros por funcionalidad, no por tipo. Una carpeta components/ con cien ficheros no ayuda a nadie; una carpeta empleados/ con todo lo relativo a empleados, sí:

src/app/
├── app.ts, app.html, app.css, app.config.ts, app.routes.ts
├── comun/                     piezas sin lógica de negocio
│   ├── avatar/
│   └── aviso/
├── empleados/
│   ├── empleado.model.ts
│   ├── lista-empleados/
│   └── tarjeta-empleado/
└── departamentos/
    ├── departamento.model.ts
    └── lista-departamentos/

Otras reglas de la convención:

  • Nombres de fichero en minúsculas y separados por guiones (kebab-case).
  • Clases en PascalCase.
  • Selectores con prefijo y en kebab-case.
  • Un componente por fichero.
  • Los ficheros de prueba, junto al fichero que prueban.
  • Los gestores de eventos se nombran por lo que hacen (guardar()), no por el evento (alPulsar()).

13. Errores frecuentes

Todos estos mensajes son reales:

Código Mensaje (resumido) Causa Solución
NG8001 'app-x' is not a known element El componente no está en imports, o el selector tiene una errata Añádelo a imports
NG8002 Can't bind to 'y' since it isn't a known property La entrada no existe o el componente no está importado Revisa el nombre y imports
NG8008 Required input 'y' from component X must be specified Falta una entrada obligatoria Pásala en la plantilla
NG8023 Multiple components match node with tagname app-x Dos componentes importados con el mismo selector Cambia uno, o importa solo uno
NG8113 All imports are unused (aviso) Sobra algo en imports Quítalo
NG8118 x is a required input and does not have a value in this context Lectura de una entrada obligatoria en el constructor Léela en ngOnInit o en un computed
NG2001 @Component is missing a template Falta template o templateUrl Añade uno
NG2008 Could not find template file './x.html' La ruta relativa es incorrecta Corrige la ruta
TS2420 Class 'X' incorrectly implements interface 'OnInit' Errata en el nombre del gancho Corrige el método
TS2305 has no exported member 'ComponentFactoryResolver' API eliminada en Angular 22 Pasa la clase a createComponent
NG0950 Input "x" is required but no value is available yet Lectura de una entrada obligatoria antes de tiempo Muévela a ngOnInit
NG0203 afterNextRender() can only be used within an injection context Llamada fuera de la construcción Pasa { injector }
NG0303 Can't set value of the 'y' input on the 'X' component Entrada inexistente en un componente dinámico Corrige el nombre
NG0911 View has already been destroyed onDestroy sobre un componente muerto Comprueba destroyed
NG0100 Expression has changed after it was checked Con Eager, un dato normal cambia en ngAfterViewInit tras dibujarse Usa una señal

Los primeros diez aparecen en ng build y los demás, en ejecución, en la consola del navegador. NG0100 solo se comprueba en modo de desarrollo, y se explica en el módulo 09.

Dos problemas más que no dan ningún error:

  • La pantalla no se actualiza. El dato no es una señal y el componente es OnPush. Pásalo a signal.
  • El estilo no se aplica. Si la regla está en el padre y quieres alcanzar el interior de un hijo, la encapsulación lo impide. Usa una variable CSS o una entrada del hijo. Si no tiene tamaño, falta :host { display: block; }.

Las Angular DevTools (módulo 01) te ayudan a depurar: la pestaña de componentes muestra el árbol real, las entradas de cada uno y sus señales. Desde la consola del navegador, en modo de desarrollo, también tienes ng.getComponent($0), que devuelve la instancia del componente del elemento seleccionado en el inspector.


14. Ejercicios

Ejercicio 1: Tarjeta de departamento

Crea TarjetaDepartamento con una entrada obligatoria de tipo Departamento (deptId, nombre, localidad, empleados), y ListaDepartamentos, que muestre los departamentos 60 y 70 de EMPRESA en dos columnas. El departamento sin empleados debe verse atenuado, mediante una clase en el anfitrión.

Ver solución
// src/app/departamentos/tarjeta-departamento.ts
import { Component, input } from '@angular/core';

export interface Departamento {
  deptId: number;
  nombre: string;
  localidad: string;
  empleados: number;
}

@Component({
  selector: 'app-tarjeta-departamento',
  template: `
    <h3>{{ departamento().nombre }}</h3>
    <p>{{ departamento().localidad }}</p>
    <p>{{ departamento().empleados }} empleados</p>
  `,
  host: {
    '[class.vacio]': 'departamento().empleados === 0',
  },
  styles: `
    :host {
      display: block;
      padding: 12px 16px;
      border: 1px solid #c5cae9;
      border-radius: 10px;
    }
    :host(.vacio) {
      opacity: 0.5;
    }
    h3 {
      margin: 0;
      color: #1a237e;
    }
    p {
      margin: 4px 0 0;
    }
  `,
})
export class TarjetaDepartamento {
  readonly departamento = input.required<Departamento>();
}
// src/app/departamentos/lista-departamentos.ts
import { Component } from '@angular/core';
import { Departamento, TarjetaDepartamento } from './tarjeta-departamento';

@Component({
  selector: 'app-lista-departamentos',
  imports: [TarjetaDepartamento],
  template: `
    @for (departamento of departamentos; track departamento.deptId) {
      <app-tarjeta-departamento [departamento]="departamento" />
    }
  `,
  styles: `
    :host {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 12px;
    }
  `,
})
export class ListaDepartamentos {
  protected readonly departamentos: readonly Departamento[] = [
    { deptId: 60, nombre: 'LOGÍSTICA', localidad: 'Zaragoza', empleados: 4 },
    { deptId: 70, nombre: 'INVESTIGACIÓN', localidad: 'Bilbao', empleados: 0 },
  ];
}

El anfitrión de ListaDepartamentos hace de rejilla, así que no hace falta ningún <div>.

Ejercicio 2: Enlace externo

Crea un componente que se aplique a <a appEnlaceExterno href="...">, abra el enlace en una pestaña nueva con rel seguro y añada una flecha tras el texto.

Ver solución
// src/app/selectores/enlace-externo.ts
import { Component } from '@angular/core';

@Component({
  selector: 'a[appEnlaceExterno]',
  template: `<ng-content /> <span aria-hidden="true">↗</span>`,
  host: {
    target: '_blank',
    rel: 'noopener noreferrer',
  },
})
export class EnlaceExterno {}
// src/app/selectores/demo-enlace.ts
import { Component } from '@angular/core';
import { EnlaceExterno } from './enlace-externo';

@Component({
  selector: 'app-demo-enlace',
  imports: [EnlaceExterno],
  template: `<a appEnlaceExterno href="https://angular.dev">Documentación</a>`,
})
export class DemoEnlace {}

El elemento sigue siendo un <a> real, con todo lo que eso supone: se puede enfocar, abrir con el botón derecho y leer con un lector de pantalla.

Ejercicio 3: Diagnóstico

Este componente tiene tres fallos que el compilador te señalará. Encuéntralos y corrígelos.

import { TitleCasePipe } from '@angular/common';
import { Component } from '@angular/core';
import { EMPLEADOS } from './datos-empleados';
import { TarjetaEmpleado } from './tarjeta-empleado';

@Component({
  selector: 'app-resumen',
  imports: [TitleCasePipe],
  template: `<app-tarjeta-empleado [empleadoo]="empleados[0]" />`,
})
export class Resumen {
  protected readonly empleados = EMPLEADOS;
}
Ver solución

El compilador da estos mensajes (resumidos):

▲ [WARNING] NG8113: All imports are unused
X [ERROR] NG8001: 'app-tarjeta-empleado' is not a known element
X [ERROR] NG8002: Can't bind to 'empleadoo' since it isn't a known property of 'app-tarjeta-empleado'.
  1. Falta TarjetaEmpleado en imports. El import de TypeScript, por sí solo, no basta.
  2. [empleadoo] tiene una errata. Si solo arreglas el primer fallo, sigue el NG8002 y aparece además NG8008: Required input 'empleado' from component TarjetaEmpleado must be specified, porque la entrada real sigue sin recibir valor.
  3. TitleCasePipe está importado y no se usa.
// src/app/empleados/resumen.ts
import { Component } from '@angular/core';
import { EMPLEADOS } from './datos-empleados';
import { TarjetaEmpleado } from './tarjeta-empleado';

@Component({
  selector: 'app-resumen',
  imports: [TarjetaEmpleado],
  template: `<app-tarjeta-empleado [empleado]="empleados[0]" />`,
})
export class Resumen {
  protected readonly empleados = EMPLEADOS;
}

Ejercicio 4: Tema con variables CSS

Crea un componente CajaTema que use dos variables CSS con valor por defecto para su color de fondo y de texto. Después, desde un padre, coloca dos cajas: una con los colores por defecto y otra personalizada, sin tocar CajaTema.

Ver solución
// src/app/estilos/caja-tema.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-caja-tema',
  template: `<p>Panel de EMPRESA</p>`,
  styles: `
    :host {
      display: block;
      padding: 8px 16px;
      background: var(--caja-fondo, #e8eaf6);
      color: var(--caja-texto, #1a237e);
    }
  `,
})
export class CajaTema {}
// src/app/estilos/demo-tema.ts
import { Component } from '@angular/core';
import { CajaTema } from './caja-tema';

@Component({
  selector: 'app-demo-tema',
  imports: [CajaTema],
  template: `
    <app-caja-tema />
    <app-caja-tema class="alerta" />
  `,
  styles: `
    .alerta {
      --caja-fondo: #ffcdd2;
      --caja-texto: #b71c1c;
    }
  `,
})
export class DemoTema {}

El padre puede aplicar .alerta a la etiqueta porque <app-caja-tema> está en su plantilla. La variable se hereda hacia dentro y atraviesa la encapsulación. El componente ofrece así una interfaz de estilo, igual que input() ofrece una de datos.

Ejercicio 5: Predice el orden

Sin ejecutarlo, escribe en qué orden aparecen los mensajes de este código. Después compruébalo.

// src/app/ciclo/orden.ts
import { AfterViewInit, Component, input, OnInit } from '@angular/core';

@Component({
  selector: 'app-hijo-orden',
  template: `<p>{{ nombre() }}</p>`,
})
export class HijoOrden implements OnInit, AfterViewInit {
  readonly nombre = input.required<string>();

  constructor() {
    console.log('hijo: constructor');
  }

  ngOnInit() {
    console.log(`hijo ${this.nombre()}: ngOnInit`);
  }

  ngAfterViewInit() {
    console.log(`hijo ${this.nombre()}: ngAfterViewInit`);
  }
}

@Component({
  selector: 'app-padre-orden',
  imports: [HijoOrden],
  template: `
    <app-hijo-orden nombre="A" />
    <app-hijo-orden nombre="B" />
  `,
})
export class PadreOrden implements OnInit, AfterViewInit {
  constructor() {
    console.log('padre: constructor');
  }

  ngOnInit() {
    console.log('padre: ngOnInit');
  }

  ngAfterViewInit() {
    console.log('padre: ngAfterViewInit');
  }
}
Ver solución

Salida real:

padre: constructor
hijo: constructor
hijo: constructor
padre: ngOnInit
hijo A: ngOnInit
hijo B: ngOnInit
hijo A: ngAfterViewInit
hijo B: ngAfterViewInit
padre: ngAfterViewInit

El padre se crea e inicia primero, porque sus hijos necesitan que haya procesado sus entradas. Después se crea cada hijo. ngAfterViewInit va al revés: la vista del padre no está terminada hasta que lo están las de sus hijos, por lo que su gancho es el último.

Ejercicio 6: Cerrar con Escape

Escribe una función reutilizable usarTeclaEscape(accion) que escuche la tecla Escape en el documento y deje de hacerlo al destruirse el componente. Úsala en un PanelLateral que muestre un texto hasta que se pulse Escape.

Ver solución
// src/app/limpieza/usar-tecla-escape.ts
import { DOCUMENT, DestroyRef, inject } from '@angular/core';

export function usarTeclaEscape(accion: () => void) {
  const documento = inject(DOCUMENT);

  const alPulsar = (evento: KeyboardEvent) => {
    if (evento.key === 'Escape') {
      accion();
    }
  };

  documento.addEventListener('keydown', alPulsar);
  inject(DestroyRef).onDestroy(() => documento.removeEventListener('keydown', alPulsar));
}
// src/app/limpieza/panel-lateral.ts
import { Component, signal } from '@angular/core';
import { usarTeclaEscape } from './usar-tecla-escape';

@Component({
  selector: 'app-panel-lateral',
  template: `
    @if (abierto()) {
      <aside>Panel abierto. Pulsa Escape para cerrar.</aside>
    } @else {
      <p>Panel cerrado</p>
    }
  `,
})
export class PanelLateral {
  protected readonly abierto = signal(true);

  constructor() {
    usarTeclaEscape(() => this.abierto.set(false));
  }
}

Las dos llamadas a inject se hacen de forma síncrona, antes de que la función termine, y por tanto dentro del contexto de inyección. Inyectar DOCUMENT en lugar de usar el global document facilita las pruebas y el SSR.

Ejercicio 7: Igualar alturas

Crea un componente con dos columnas de texto de distinta longitud. Con afterNextRender y las fases adecuadas, mide la altura de ambas y aplica la mayor a las dos. Muestra en read la altura final.

Ver solución
// src/app/render/columnas-iguales.ts
import { afterNextRender, Component, ElementRef, viewChildren } from '@angular/core';

@Component({
  selector: 'app-columnas-iguales',
  template: `
    <div #columna class="columna">Texto corto</div>
    <div #columna class="columna">
      Texto bastante más largo, que ocupa varias líneas en una columna estrecha y
      obliga a esta caja a crecer bastante más que su compañera de la izquierda.
    </div>
  `,
  styles: `
    :host {
      display: flex;
      align-items: flex-start;
      gap: 10px;
    }
    .columna {
      width: 160px;
      padding: 8px;
      background: #e8eaf6;
    }
  `,
})
export class ColumnasIguales {
  private readonly columnas = viewChildren<string, ElementRef<HTMLElement>>('columna', { read: ElementRef });

  constructor() {
    afterNextRender({
      earlyRead: () => Math.max(...this.columnas().map((c) => c.nativeElement.offsetHeight)),
      write: (maxima) => {
        for (const columna of this.columnas()) {
          columna.nativeElement.style.boxSizing = 'border-box';
          columna.nativeElement.style.height = `${maxima}px`;
        }
      },
      read: () => {
        console.log('alturas:', this.columnas().map((c) => c.nativeElement.offsetHeight));
      },
    });
  }
}

La lectura va en earlyRead, antes de escribir, y la comprobación en read, después. Si dos componentes de la página hicieran lo mismo, Angular agruparía sus lecturas y sus escrituras, y el navegador solo recalcularía el diseño una vez.

Ejercicio 8: Vista intercambiable

Crea VistaTarjetas y VistaTabla, que reciban empleados por una entrada. Un componente padre debe alternar entre ellas con NgComponentOutlet, guardando la clase en una señal.

Ver solución
// src/app/empleados/vistas.ts
import { NgComponentOutlet } from '@angular/common';
import { Component, input, signal, Type } from '@angular/core';
import { EMPLEADOS } from './datos-empleados';
import { Empleado } from './empleado.model';
import { TarjetaEmpleado } from './tarjeta-empleado';

@Component({
  selector: 'app-vista-tarjetas',
  imports: [TarjetaEmpleado],
  template: `
    @for (empleado of empleados(); track empleado.empId) {
      <app-tarjeta-empleado [empleado]="empleado" />
    }
  `,
})
export class VistaTarjetas {
  readonly empleados = input.required<readonly Empleado[]>();
}

@Component({
  selector: 'app-vista-tabla',
  template: `
    <table>
      @for (empleado of empleados(); track empleado.empId) {
        <tr>
          <td>{{ empleado.empId }}</td>
          <td>{{ empleado.nombre }}</td>
          <td>{{ empleado.salario }} €</td>
        </tr>
      }
    </table>
  `,
})
export class VistaTabla {
  readonly empleados = input.required<readonly Empleado[]>();
}

@Component({
  selector: 'app-vista-empleados',
  imports: [NgComponentOutlet],
  template: `
    <button id="tarjetas" type="button" (click)="vista.set(tarjetas)">Tarjetas</button>
    <button id="tabla" type="button" (click)="vista.set(tabla)">Tabla</button>
    <ng-container *ngComponentOutlet="vista(); inputs: { empleados }" />
  `,
})
export class VistaEmpleados {
  protected readonly tarjetas = VistaTarjetas;
  protected readonly tabla = VistaTabla;
  protected readonly empleados = EMPLEADOS;

  protected readonly vista = signal<Type<unknown>>(VistaTarjetas);
}

Con solo dos opciones conocidas, un @switch sería más sencillo en un caso real. NgComponentOutlet compensa cuando la lista de vistas viene de fuera, por ejemplo de una configuración.

Ejercicio 9: Avisos que se cierran solos

Partiendo de la sección 11, crea un AvisoTemporal que emita cerrar pasados duracion milisegundos, y una ColaAvisos que los cree con ViewContainerRef. El temporizador debe cancelarse si el aviso se destruye antes, por ejemplo con un botón “Vaciar”.

Ver solución
// src/app/dinamicos/aviso-temporal.ts
import { Component, DestroyRef, inject, input, OnInit, output } from '@angular/core';

@Component({
  selector: 'app-aviso-temporal',
  template: `<p class="aviso">{{ mensaje() }}</p>`,
})
export class AvisoTemporal implements OnInit {
  readonly mensaje = input.required<string>();
  readonly duracion = input(3000);
  readonly cerrar = output<void>();

  private readonly destroyRef = inject(DestroyRef);

  ngOnInit() {
    // En ngOnInit, y no en el constructor: necesita la entrada duracion
    const id = setTimeout(() => this.cerrar.emit(), this.duracion());
    this.destroyRef.onDestroy(() => clearTimeout(id));
  }
}
// src/app/dinamicos/cola-avisos.ts
import { Component, ComponentRef, inputBinding, outputBinding, viewChild, ViewContainerRef } from '@angular/core';
import { AvisoTemporal } from './aviso-temporal';

@Component({
  selector: 'app-cola-avisos',
  template: `
    <button id="nuevo" type="button" (click)="nuevo()">Nuevo aviso</button>
    <button id="vaciar" type="button" (click)="vaciar()">Vaciar</button>
    <ng-container #ancla />
  `,
})
export class ColaAvisos {
  private readonly ancla = viewChild.required('ancla', { read: ViewContainerRef });

  private contador = 0;

  protected vaciar() {
    this.ancla().clear();
  }

  protected nuevo() {
    const numero = ++this.contador;
    const ref: ComponentRef<AvisoTemporal> = this.ancla().createComponent(AvisoTemporal, {
      bindings: [
        inputBinding('mensaje', () => `Aviso ${numero}`),
        inputBinding('duracion', () => 500),
        outputBinding('cerrar', () => ref.destroy()),
      ],
    });
  }
}

Si se destruye el aviso antes de tiempo, DestroyRef cancela el temporizador. Sin esa línea, el temporizador seguiría vivo tras destruirse el componente y acabaría llamando a emit para nada.

Ejercicio 10 (propuesto): Gráfico adaptable

Amplía GraficoSalarios para que el <canvas> ocupe todo el ancho de su contenedor y se redibuje al cambiar de tamaño. Usa un ResizeObserver que guarde el ancho en una señal, e inícialo en afterNextRender. Lee esa señal dentro de afterRenderEffect, y desconecta el observador con DestroyRef.

Ejercicio 11 (propuesto): Modo oscuro por contexto

Añade a TarjetaEmpleado una variante oscura con :host-context(.oscuro), y un botón en ListaEmpleados que ponga o quite esa clase en un contenedor. Hazlo después con variables CSS y compara las dos soluciones.

Ejercicio 12 (propuesto): Reorganizar un proyecto

Coge los componentes de este módulo y colócalos en la estructura de la sección 12, separando comun/, empleados/ y departamentos/. Comprueba con ng build que no queda ninguna ruta rota, y que no aparece ningún aviso de imports sin usar.


Resumen

Concepto API Idea clave
Componente @Component Clase más plantilla, estilos y selector
Selector selector Selector CSS: elemento, o atributo sobre un elemento nativo
Dependencias de plantilla imports Cada componente declara lo que usa
Plantilla template, templateUrl En línea si es corta; en fichero si no
Estilos styles, styleUrl Aislados por defecto con atributos generados
Encapsulación ViewEncapsulation Emulated salvo motivo claro
Elemento anfitrión :host, host Recuerda display: block
Estrategia por defecto OnPush 🔄 v22.0 Guarda en señales lo que se muestra
Inicialización ngOnInit Primer momento con entradas disponibles
Otros ganchos ngOnChanges, ngAfterViewInit, ngOnDestroy Casi siempre sustituibles por señales y funciones
DOM listo, una vez afterNextRender Medir, dar el foco, iniciar bibliotecas
DOM sincronizado afterRenderEffect Sigue señales y se ejecuta tras el render
Fases earlyRead, write, mixedReadWrite, read Agrupan lecturas y escrituras
Limpieza DestroyRef Funciones reutilizables que se limpian solas
Componente como dato NgComponentOutlet Declarativo, sin tipado de entradas
Crear desde código ViewContainerRef.createComponent bindings con inputBinding, outputBinding, twoWayBinding
Fuera del árbol createComponent, ApplicationRef.attachView Diálogos en <body>
Nombres tarjeta-empleado.ts, TarjetaEmpleado Sin sufijo, por lo que hace la clase