Módulo 04: Plantillas

Objetivos del módulo

  • Entender qué es una plantilla, en qué se convierte al compilar y por qué sus errores aparecen en el build
  • Mostrar datos con interpolación y saber qué se pinta con null, objetos o HTML
  • Escribir expresiones correctas: qué sintaxis admiten, qué ven y por qué no deben tener efectos secundarios
  • Distinguir atributo HTML y propiedad DOM, y usar [propiedad], [attr.x], [class.x] y [style.x]
  • Responder a eventos con (evento), $event y los filtros de teclado
  • Controlar el flujo con @if, @for con track, @switch y @let
  • Elegir la clave de track correcta y razonar qué ocurre con el DOM en cada caso
  • Acceder a elementos y componentes con variables de plantilla (#ref)
  • Reutilizar fragmentos con ng-template, NgTemplateOutlet y ng-container
  • Comentar atributos y leer miembros privados, y saber qué cambia en la versión LTS
  • Interpretar los errores de plantilla más frecuentes

1. Qué es una plantilla

En el módulo 03 viste que un componente une una clase TypeScript con una vista. La plantilla (template) es esa vista: un trozo de texto que parece HTML, pero con sintaxis añadida para enlazarlo con la clase.

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

@Component({
  selector: 'app-saludo',
  template: `
    <h1>{{ titulo() }}</h1>
    <button (click)="sumar()">+1</button>
  `,
})
export class Saludo {
  protected readonly titulo = signal('Hola');
  protected sumar() {}
}

La plantilla puede ir en línea, con template, o en un fichero aparte, con templateUrl: './saludo.html', que es lo que genera ng generate component. Para el compilador son equivalentes. En este módulo la mayoría de los ejemplos van en línea para ver la clase y la vista de un vistazo.

📘 Concepto: Una signal es una caja que guarda un valor: se lee llamándola como a una función, titulo(), y se cambia con .set() o .update(). Angular sabe qué partes de la vista la leen y las actualiza cuando cambia. Por ahora basta con esto; el módulo 05 las explica a fondo, junto con computed, que aquí usaremos para valores derivados.

Una plantilla no es HTML

El navegador nunca ve ese texto. El compilador de Angular lo lee durante el build, lo comprueba y lo convierte en una función JavaScript. Esta es la salida real para el componente anterior, abreviada:

// if (rf & 1): bloque de creación, una sola vez
i0.ɵɵdomElementStart(0, "h1");
i0.ɵɵtext(1);
i0.ɵɵdomElementEnd();
i0.ɵɵdomElementStart(2, "button", 0);
i0.ɵɵdomListener("click", function () { return ctx.sumar(); });
i0.ɵɵtext(3, "+1");
i0.ɵɵdomElementEnd();

// if (rf & 2): bloque de actualización, cada vez que se revisa la vista
i0.ɵɵadvance();
i0.ɵɵtextInterpolate(ctx.titulo());

No hace falta memorizar esas instrucciones, pero sí entender dos ideas que explican casi todo lo que sigue:

  • Hay dos fases. La creación construye los nodos y los listeners. La actualización solo vuelve a leer las expresiones y toca el nodo cuyo valor ha cambiado. Por eso Angular no regenera la página entera.
  • ctx es la instancia del componente. Todo lo que escribes en una expresión se busca ahí. titulo() significa ctx.titulo(). No hay acceso a variables globales.

Flujo desde la plantilla al compilador, la función de plantilla y el DOM real

Figura 1. La plantilla se compila durante el build; en el navegador solo se ejecuta la función generada.

Comprobación de tipos

Como la plantilla se compila, se comprueba igual que el TypeScript. Con esta plantilla:

<p>{{ noExiste }}</p>

ng build se detiene:

error TS2339: Property 'noExiste' does not exist on type 'Saludo'.

El modo estricto de plantillas, strictTemplates, es el responsable de buena parte de estas comprobaciones. En un proyecto nuevo está activo en ambas versiones: Angular 22 lo aplica de forma implícita y Angular 21 lo escribe en tsconfig.json. Si un proyecto de la LTS lo tiene desactivado, verás menos errores: varios de los de este módulo no aparecen.

Los enlaces en un vistazo

Una plantilla habla con su clase mediante unos pocos tipos de enlace (binding). El resto del módulo los recorre uno a uno.

Enlaces de la clase al DOM con interpolación, propiedades, atributos, clases y estilos, y del DOM a la clase con eventos

Figura 2. Los datos bajan de la clase al DOM con {{ }} y [ ], y los sucesos suben del DOM a la clase con ( ).


2. Interpolación

La interpolación inserta el valor de una expresión como texto: {{ expresion }}. Angular convierte el resultado a cadena y lo escribe en un nodo de texto.

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

@Component({
  selector: 'app-interpolacion',
  template: `
    <h2>Lo que pinta la interpolación</h2>
    <table>
      <tr><th>Expresión</th><th>Resultado en pantalla</th></tr>
      <tr><td><code>texto</code></td><td>{{ texto() }}</td></tr>
      <tr><td><code>numero</code></td><td>{{ numero() }}</td></tr>
      <tr><td><code>numero * 12</code></td><td>{{ numero() * 12 }}</td></tr>
      <tr><td><code>activo</code></td><td>{{ activo() }}</td></tr>
      <tr><td><code>nulo</code></td><td>[{{ nulo() }}]</td></tr>
      <tr><td><code>indefinido</code></td><td>[{{ indefinido() }}]</td></tr>
      <tr><td><code>lista</code></td><td>{{ lista() }}</td></tr>
      <tr><td><code>objeto</code></td><td>{{ objeto() }}</td></tr>
      <tr><td><code>html</code></td><td>{{ html() }}</td></tr>
    </table>
  `,
})
export class Interpolacion {
  protected readonly texto = signal('Ana García');
  protected readonly numero = signal(2200.5);
  protected readonly activo = signal(true);
  protected readonly nulo = signal(null);
  protected readonly indefinido = signal(undefined);
  protected readonly lista = signal(['Madrid', 'Valencia']);
  protected readonly objeto = signal({ nombre: 'VENTAS' });
  protected readonly html = signal('<b>negrita</b>');
}

Tabla con el resultado de interpolar texto, números, booleanos, null, undefined, un array, un objeto y una cadena con HTML

Figura 3. Resultado real de cada tipo de valor.

Fíjate en cuatro detalles que suelen sorprender:

  • null y undefined no se pintan. Aparece una cadena vacía, no la palabra null. Los corchetes del ejemplo están puestos a mano para que se note el hueco.
  • Un objeto muestra [object Object]. Es lo que devuelve su toString(). Hay que pintar sus propiedades, o usar el pipe json para depurar (módulo 07).
  • Un array se une con comas, sin espacio, porque también se usa su toString().
  • El HTML no se interpreta. <b>negrita</b> sale literal, con sus símbolos < y > visibles. Angular escribe texto, no marcado, así que un dato malicioso no puede inyectar etiquetas. Esta protección contra XSS es gratuita mientras uses interpolación.

La interpolación también sirve dentro del valor de un atributo. Esta línea:

<p title="Hola, {{ nombre() }}">Con interpolación</p>

produce title="Hola, Ana". Es útil para mezclar texto fijo con datos. Si el valor es una sola expresión, es más claro [title]="nombre()", como verás en la sección 4.


3. Expresiones

Lo que va dentro de {{ }} o entre las comillas de un [enlace] es una expresión de plantilla. Se parece a una expresión de JavaScript, pero no es JavaScript: tiene su propio analizador y un ámbito distinto.

Qué ve una expresión

Una expresión solo ve tres cosas, y las busca en este orden: variables locales de la plantilla (@let, variables de @for, #ref), miembros de la clase del componente y unos pocos nombres especiales como $any o $event.

No ve el ámbito global:

<p>{{ Math.round(1.6) }}</p>
error TS2339: Property 'Math' does not exist on type 'Saludo'.

El error lo dice claro: busca Math en la clase. Lo mismo ocurre con Date, console, window o localStorage. Si de verdad necesitas algo global, crea un método en la clase o expón el objeto en una propiedad:

protected readonly Date = Date;

protected redondear(n: number) {
  return Math.round(n);
}

Sintaxis admitida

Sintaxis Ejemplo Resultado real
Aritmética y comparación numero() * 12 26406
Ternario activo() ? 'Sí' : 'No' Sí
Encadenamiento opcional sinJefe?.nombre Vacío
Fusión de nulos sinJefe?.nombre ?? 'Sin jefe' Sin jefe
Aserción de no nulo nombre!.length 3
Plantilla literal `Hola, ${nombre}` Hola, Ana
Plantilla etiquetada etiqueta`[${nombre}]` [ANA]
typeof typeof nombre string
in 'salario' in empleado true
instanceof fecha instanceof Date true (con Date expuesto en la clase)
Exponente 2 ** 10 1024
void void nombre Vacío
Expresión regular /^A/.test(nombre) true
Función flecha empleados.filter(e => e.comision !== null).length 2
Propagación en array [...nombres, 'Luis'].length 3
Propagación en objeto ({ ...base, b: 2 }).b 2
Propagación en llamada mayor(...numeros) 7
Escape de tipos $any(empleado).inventado Vacío

Todas estas formas compilan y dan ese resultado tanto en Angular 22.2 como en la LTS 21.2.

Sigue habiendo cosas prohibidas. new y los operadores de incremento no existen en las expresiones:

<p>{{ new Date() }}</p>
<button (click)="n++">x</button>
error NG5002: Parser Error: Unexpected token 'Date' at column 6 in [ new Date() ]
error NG5002: Parser Error: Unexpected end of expression: n++ at the end of the expression [n++]

Que algo se pueda escribir no significa que convenga. Una plantilla con una función flecha de tres líneas es difícil de leer, de probar y de depurar. Mantén las expresiones cortas y lleva la lógica a la clase, normalmente a un computed.

💡 Consejo: $any() desactiva la comprobación de tipos de una expresión. Es una salida de emergencia para código que aún no puedes tipar, no una solución. Cada $any es un error de tipos que has decidido no ver.

Resultado de ?. 🔄 v22.0

🔄 Cambia en Angular 22.0. En la LTS (Angular 21), a?.b en una plantilla da null cuando a es nulo; en 22 da undefined, igual que en TypeScript. Si usas la LTS: no compares con === undefined; escribe ?? 'valor' o == null, que funcionan igual en las dos versiones.

Durante años las plantillas se desviaron de JavaScript en este punto. Con sinJefe a null se obtiene:

Expresión Angular 22.2 Angular 21.2
sinJefe?.nombre === undefined true false
sinJefe?.nombre === null false true
sinJefe?.nombre ?? 'Sin jefe' Sin jefe Sin jefe

Solo notarás la diferencia si comparas el resultado de forma estricta o se lo pasas a algo que distinga null de undefined, como un input() de tipo string | null. Al pasar de 21 a 22, ng update protege el código existente: envuelve cada uso en $safeNavigationMigration(), una función de plantilla que conserva el resultado null, y desactiva dos avisos que el cambio haría saltar. En código nuevo no la escribas. El módulo 28 detalla la migración.

Sin efectos secundarios

Una expresión se evalúa cada vez que Angular revisa la vista, y tú no controlas cuándo ni cuántas veces ocurre. Por tanto debe ser pura: leer datos y devolver un resultado, sin cambiar nada. Una expresión como {{ contador.set(contador() + 1) }} modificaría el estado durante el pintado.

Métodos frente a computed 🔄 v22.0

🔄 Cambia en Angular 22.0. Un componente sin changeDetection usa OnPush; en la LTS (Angular 21) el valor por defecto es Eager, y en la compilación de desarrollo cada método de plantilla se ejecuta dos veces por revisión. Si usas la LTS: no tienes que cambiar nada: en producción el recuento es el mismo, y computed se comporta igual en las dos versiones.

Este componente calcula lo mismo de dos formas y escribe en consola cada vez que lo hace:

import { Component, computed, signal } from '@angular/core';
import { EMPLEADOS } from '../datos';
import { Contador } from './contador';

@Component({
  selector: 'app-metodo-computed',
  imports: [Contador],
  template: `
    <h3>{{ titulo() }}</h3>
    <p>Con método: {{ totalMetodo() }} €</p>
    <p>Con computed: {{ totalComputed() }} €</p>
    <button (click)="titulo.set(titulo() + '!')">Cambiar el título</button>
    <button (click)="nada()">Listener vacío</button>
    <app-contador />
  `,
})
export class MetodoComputed {
  protected readonly titulo = signal('Plantilla');
  protected readonly empleados = signal(EMPLEADOS);

  protected nada() {}

  protected totalMetodo() {
    console.log('método');
    return this.empleados().reduce((s, e) => s + e.salario, 0);
  }

  protected readonly totalComputed = computed(() => {
    console.log('computed');
    return this.empleados().reduce((s, e) => s + e.salario, 0);
  });
}

Estas son las veces que se ejecuta el método, contadas en la consola del navegador:

Acción 22.2, desarrollo 21.2, desarrollo 22.2 y 21.2, producción computed, siempre
Cargar la página 1 2 1 1
Cambiar el título 1 2 1 0
Pulsar el listener vacío 1 2 1 0
Clic donde no hay listener 0 0 0 0

El doble recuento de la LTS se debe a que, en desarrollo, Angular repasa las vistas Eager una segunda vez para detectar cambios indebidos durante el pintado. Ese repaso no existe en producción.

El método se ejecuta en cada revisión de la vista, aunque los empleados no hayan cambiado. Basta con cambiar el título, o con pulsar un botón cuyo listener no hace nada: un evento atendido marca la vista para revisarla. Incluso el botón +1 del componente hijo hace que el padre vuelva a calcular, porque el evento marca toda la rama hasta la raíz: en desarrollo son 1 llamada en 22.2 y 2 en 21.2. El computed, en cambio, guarda su resultado y solo recalcula cuando cambia una señal que ha leído.

Con 6 empleados no importa. Con miles de filas y un reduce en cada celda, sí.

💡 Consejo: Un método en la plantilla es correcto para cálculos triviales. Si recorre listas, ordena o crea objetos, conviértelo en computed. El módulo 09 explica la detección de cambios por dentro.


4. Enlace de propiedades

Atributo y propiedad no son lo mismo

Este es el concepto que más confusión genera, y merece un rato. Cuando el navegador lee <input value="Ana">:

  1. El atributo value="Ana" forma parte del HTML. Es texto y representa el valor inicial.
  2. El navegador crea un objeto HTMLInputElement. Sus propiedades, como input.value o input.disabled, tienen tipo y representan el estado actual.

Si el usuario teclea, cambia la propiedad, pero el atributo sigue igual. [value]="nombre()" escribe en la propiedad. Estos valores están medidos en el navegador con el ejemplo de abajo:

Momento input.getAttribute('value') input.value
Tras cargar null Ana
Tras teclear null Escrito por mí
Tras nombre.set('Carmen') null Carmen

El atributo ni siquiera llega a existir, porque Angular asigna directamente input.value = ....

Un input con su atributo HTML inicial y su objeto DOM con las propiedades value y disabled

Figura 4. El atributo describe el HTML inicial; la propiedad guarda el estado actual del objeto.

Sintaxis

<input [value]="nombre()" [disabled]="bloqueado()" />

Los corchetes dicen dos cosas: que el valor es una expresión, no un texto, y que se asigna a una propiedad del objeto DOM. Comparar con y sin corchetes aclara la diferencia:

<input disabled="false" />     <!-- Deshabilitado: basta con que el atributo exista -->
<input [disabled]="false" />   <!-- Habilitado: asigna el booleano false -->

El primer caso es un error clásico. Los atributos booleanos de HTML funcionan por presencia, así que disabled="false" deshabilita el campo. Con corchetes pasas un booleano de verdad.

Cuando disabled es true, el navegador refleja la propiedad en el atributo: en el ejemplo, input.hasAttribute('disabled') da false al principio y true tras bloquear. Algunas propiedades se reflejan y otras no; Angular solo escribe la propiedad y el resto es cosa del navegador.

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

@Component({
  selector: 'app-propiedades',
  template: `
    <label>Nombre: <input [value]="nombre()" [disabled]="bloqueado()" /></label>
    <button (click)="bloqueado.set(!bloqueado())">
      {{ bloqueado() ? 'Desbloquear' : 'Bloquear' }}
    </button>
    <button (click)="nombre.set('Carmen')">Cambiar nombre</button>

    <h3>[innerHTML] con contenido no confiable</h3>
    <div class="contenido" [innerHTML]="comentario()"></div>
  `,
})
export class Propiedades {
  protected readonly nombre = signal('Ana');
  protected readonly bloqueado = signal(false);
  protected readonly comentario = signal(
    '<b>Buen trabajo</b> <img src="x" onerror="alert(1)"> <script>alert(2)</script>',
  );
}

Campo bloqueado con el texto Carmen y un comentario en negrita con una imagen rota, sin ningún script ejecutado

Figura 5. [innerHTML] conserva la negrita y elimina el código peligroso. La imagen aparece rota porque su src no existe.

[innerHTML] y el saneado

A veces necesitas pintar HTML que viene de los datos. [innerHTML] lo interpreta, y aquí vuelve a aparecer el riesgo de XSS. Angular pasa el valor por un saneador antes de asignarlo. El DOM resultante del ejemplo es:

<b>Buen trabajo</b> <img src="x">

El onerror y el <script> han desaparecido, y la consola muestra un aviso real:

WARNING: sanitizing HTML stripped some content, see https://angular.dev/best-practices/security#preventing-cross-site-scripting-xss

El saneador te protege, pero no sustituye a la prudencia. Usa [innerHTML] solo cuando necesites marcado. Para texto, {{ }} o [textContent]. El módulo 27 trata la seguridad en profundidad.

Nombres de propiedad

El nombre entre corchetes es el de la propiedad DOM, que no siempre coincide con el del atributo:

Atributo HTML Propiedad DOM
class className
for htmlFor
colspan colSpan
readonly readOnly
tabindex tabIndex

Por eso esto falla:

<td [colspan]="2"></td>
error NG8002: Can't bind to 'colspan' since it isn't a known property of 'td'.

Tienes dos arreglos: la propiedad correcta, [colSpan], o el enlace de atributo que viene a continuación.


5. Enlace de atributos

Algunos atributos no tienen una propiedad equivalente: los aria-*, los data-* o colspan en su forma en minúsculas. Para ellos existe [attr.nombre], que llama por dentro a setAttribute.

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

@Component({
  selector: 'app-atributos',
  template: `
    <table>
      <tr><th [attr.colspan]="columnas()">Empleados</th></tr>
      <tr><td colspan="2">Sin datos</td></tr>
    </table>
    <button [attr.aria-label]="'Eliminar a ' + nombre()" [attr.aria-pressed]="pulsado()">X</button>
    <p [attr.data-emp-id]="100" data-origen="estatico">Ana</p>
    <p [attr.title]="pista()">Sin pista</p>
  `,
})
export class Atributos {
  protected readonly columnas = signal(2);
  protected readonly nombre = signal('Ana');
  protected readonly pulsado = signal(false);
  protected readonly pista = signal<string | null>(null);
}

Este es el HTML que queda en el navegador:

<th colspan="2">Empleados</th>
<button aria-label="Eliminar a Ana" aria-pressed="false">X</button>
<p data-origen="estatico" data-emp-id="100">Ana</p>
<p>Sin pista</p>

Observa tres cosas:

  • aria-pressed="false" conserva su valor en texto. Para ARIA es justo lo que se necesita, porque el atributo debe decir "false", no desaparecer.
  • [attr.title] con valor null elimina el atributo. No queda title="" ni title="null". Es la forma limpia de quitar un atributo condicionalmente.
  • Los atributos estáticos, como colspan="2" en el td o data-origen, se quedan como HTML normal y no necesitan corchetes.
Quieres Escribe
Estado de un elemento (value, disabled, checked, src) [propiedad]
Accesibilidad (aria-*, role dinámico) [attr.aria-x]
Datos para CSS o JavaScript externo [attr.data-x]
Un atributo que debe poder desaparecer [attr.x] con null
Pasar datos a un componente hijo [entrada] (ver módulo 06)

6. Clases y estilos

Una clase cada vez

[class.nombre] añade o quita una clase según una condición. Respeta las clases estáticas del elemento.

<div class="tarjeta" [class.activa]="activa()">...</div>

Varias clases

[class] acepta un objeto con condiciones, un array o una cadena:

<div class="tarjeta" [class]="clases()">Objeto</div>
<div class="tarjeta" [class]="['destacada', 'activa']">Array</div>
protected readonly clases = computed(() => ({
  destacada: this.uso() > 60,
  baja: this.uso() < 20,
}));

En el navegador, con uso a 90, la clase resultante es tarjeta destacada. La estática tarjeta se conserva: Angular combina ambas fuentes.

Estilos

[style.propiedad] fija una propiedad CSS. Puedes añadir la unidad al final del nombre, y también enlazar variables CSS:

<p [style.color]="uso() > 80 ? '#c62828' : '#2e7d32'" [style.font-size.px]="18">
  Presupuesto usado: {{ uso() }} %
</p>
<div class="barra" [style.--color-relleno]="colorBarra()">
  <div class="relleno" [style.width.%]="uso()"></div>
</div>
<p [style]="estilos()">[style] con un objeto</p>

[style] acepta un objeto. Ahí el sufijo de unidad no funciona: al probar la clave 'letter-spacing.px': 1, el estilo se ignoró sin avisar. Escribe el valor completo:

protected readonly estilos = computed(() => ({
  'font-weight': 'bold',
  'letter-spacing': '1px',
}));

El ejemplo completo, con sus estilos:

import { Component, computed, signal } from '@angular/core';

@Component({
  selector: 'app-clases-estilos',
  styles: `
    .tarjeta { border: 1px solid #c5cae9; border-radius: 8px; padding: 12px; margin-bottom: 12px; }
    .activa { border-color: #283593; border-width: 2px; }
    .destacada { background: #e8eaf6; }
    .baja { opacity: 0.5; }
    .barra { background: #eceff1; height: 16px; border-radius: 8px; overflow: hidden; }
    .relleno { background: var(--color-relleno); height: 100%; }
  `,
  template: `
    <div class="tarjeta" [class.activa]="activa()">[class.activa]</div>
    <div class="tarjeta" [class]="clases()">[class] con un objeto</div>
    <div class="tarjeta" [class]="['destacada', 'activa']">[class] con un array</div>

    <p [style.color]="uso() > 80 ? '#c62828' : '#2e7d32'" [style.font-size.px]="18">
      Presupuesto usado: {{ uso() }} %
    </p>
    <div class="barra" [style.--color-relleno]="colorBarra()">
      <div class="relleno" [style.width.%]="uso()"></div>
    </div>
    <p [style]="estilos()">[style] con un objeto</p>

    <button (click)="activa.set(!activa())">Alternar activa</button>
    <button (click)="uso.set(uso() + 10)">+10 %</button>
    <button (click)="uso.set(50)">Reiniciar</button>
  `,
})
export class ClasesEstilos {
  protected readonly activa = signal(true);
  protected readonly uso = signal(50);
  protected readonly clases = computed(() => ({
    destacada: this.uso() > 60,
    baja: this.uso() < 20,
  }));
  protected readonly colorBarra = computed(() => (this.uso() > 80 ? '#ff7043' : '#3f51b5'));
  protected readonly estilos = computed(() => ({
    'font-weight': 'bold',
    'letter-spacing': '1px',
  }));
}

Tres tarjetas con distintas clases, un texto rojo con el 90 por ciento y una barra naranja casi llena

Figura 6. Estado tras pulsar cuatro veces +10 %: la segunda tarjeta se destaca y la barra cambia de color mediante una variable CSS.

⚠️ Importante: NgClass y NgStyle son directivas antiguas que aún verás en código real. Hoy [class] y [style] hacen lo mismo sin importar nada. Si copias [ngClass] sin importar la directiva, el error es NG8002: Can't bind to 'ngClass' since it isn't a known property of 'div'.

💡 Consejo: Antes de escribir un estilo en línea, piensa si no es mejor una clase. Reserva [style] para valores calculados, como un porcentaje, y deja el aspecto en el CSS del componente.


7. Eventos

Sintaxis

Los paréntesis escuchan un evento y ejecutan una sentencia cuando ocurre:

<button (click)="sumar()">Pulsado {{ veces() }} veces</button>

A diferencia de una expresión, una sentencia sí puede cambiar el estado, y admite varias instrucciones separadas por ;. Aun así, lo normal es llamar a un método. Sirve cualquier evento del DOM: input, change, submit, mouseenter, focus, blur…

⚠️ Importante: Angular no comprueba que el evento exista en un elemento nativo. (clcik)="x()" en un <div> compila sin ningún aviso y simplemente nunca se dispara. Si un botón no reacciona, revisa primero la ortografía del evento.

$event

Dentro de la sentencia, $event es el objeto del evento. Su tipo depende del evento, y Angular lo conoce: en (click) es MouseEvent, y en (keydown) es KeyboardEvent.

<input [value]="texto()" (input)="alEscribir($event)" />
protected alEscribir(evento: Event) {
  const caja = evento.target as HTMLInputElement;
  this.texto.set(caja.value);
}

El as HTMLInputElement es necesario porque target es un EventTarget genérico: TypeScript no puede saber que viene de un input.

[value] más (input) forman un enlace en los dos sentidos hecho a mano. Angular tiene una sintaxis abreviada, [( )], que verás con los componentes en el módulo 06 y con los formularios en el módulo 16.

Filtros de teclado

Angular permite filtrar por tecla en el propio nombre del evento:

<input (keyup.enter)="confirmar()" (keydown.escape)="texto.set('')" />
<input (keydown.control.s)="guardar($event)" />

El tercer ejemplo combina un modificador con una tecla. Como Ctrl+S abre el diálogo de guardar del navegador, hay que evitarlo:

protected guardar(evento: Event) {
  evento.preventDefault();
  this.anotar('guardado con Ctrl+S');
}

Burbujeo y acción por defecto

Los eventos del DOM suben desde el elemento hasta sus antecesores. Esto es JavaScript puro, y lo repasa el Curso de JavaScript. En una plantilla se controla con los métodos de $event:

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

@Component({
  selector: 'app-eventos',
  styles: `
    .zona { border: 1px dashed #3f51b5; padding: 12px; margin: 12px 0; }
    li { font-family: Consolas, monospace; }
  `,
  template: `
    <button (click)="sumar()">Pulsado {{ veces() }} veces</button>

    <p>
      <input placeholder="Escribe y pulsa Intro"
             [value]="texto()"
             (input)="alEscribir($event)"
             (keyup.enter)="confirmar()"
             (keydown.escape)="texto.set('')" />
    </p>
    <p>Confirmado: <strong>{{ confirmado() }}</strong></p>

    <div class="zona" (click)="anotar('clic en la zona')">
      <button (click)="anotar('botón normal')">Burbujea</button>
      <button (click)="anotar('botón parado'); $event.stopPropagation()">No burbujea</button>
    </div>

    <p><a href="https://angular.dev" (click)="$event.preventDefault(); anotar('enlace interceptado')">angular.dev</a></p>

    <input placeholder="Prueba Ctrl+S" (keydown.control.s)="guardar($event)" />

    <ul>
      @for (linea of registro(); track $index) {
        <li>{{ linea }}</li>
      }
    </ul>
  `,
})
export class Eventos {
  protected readonly veces = signal(0);
  protected readonly texto = signal('');
  protected readonly confirmado = signal('(nada)');
  protected readonly registro = signal<string[]>([]);

  protected sumar() {
    this.veces.update((v) => v + 1);
  }

  protected alEscribir(evento: Event) {
    const caja = evento.target as HTMLInputElement;
    this.texto.set(caja.value);
  }

  protected confirmar() {
    this.confirmado.set(this.texto());
  }

  protected anotar(mensaje: string) {
    this.registro.update((r) => [...r, mensaje]);
  }

  protected guardar(evento: Event) {
    evento.preventDefault();
    this.anotar('guardado con Ctrl+S');
  }
}

Página de eventos con el contador a 2, el texto confirmado y un registro de cinco líneas

Figura 7. Tras la secuencia de pruebas, el registro muestra que el botón normal activa también la zona y que el botón parado no.

El registro confirma el comportamiento. Un clic en «Burbujea» deja dos líneas, primero la del botón y luego la de la zona. Un clic en «No burbujea» deja solo una. Y al pulsar el enlace, la URL no cambia.

💡 Consejo: Esa @for del registro aparecerá explicada en la sección 9. No te preocupes aún por su sintaxis.


8. @if: mostrar según una condición

Sintaxis

@if (cargando()) {
  <p>Cargando...</p>
} @else if (error()) {
  <p>Ha fallado la carga.</p>
} @else {
  <p>Datos listos.</p>
}

Es control de flujo integrado: forma parte del lenguaje de plantillas y no hay que importar nada. Los paréntesis y las llaves son obligatorios. El contenido desaparece del DOM cuando la condición es falsa: no se oculta con CSS, se destruye, y se crea de nuevo cuando vuelve a ser cierta. Esto importa si dentro hay un componente con estado o un formulario a medio rellenar, porque ese estado se pierde.

📘 Concepto: En código anterior a Angular 17 verás *ngIf, y también *ngFor y [ngSwitch]. Son directivas estructurales que @if, @for y @switch sustituyen. En este curso solo usamos la sintaxis moderna; las antiguas se explican en el módulo 28 para que puedas leerlas.

Alias con as

@if (empleado(); as e) {
  <p>{{ e.nombre }}</p>
}

as guarda el resultado de la condición en una variable local. Tiene dos ventajas: evita repetir la lectura y, sobre todo, estrecha el tipo. Sin alias no compila:

protected readonly jefe = signal<Empleado | null>(null);
@if (jefe()) {
  <p>{{ jefe().nombre }}</p>
}
error TS2531: Object is possibly 'null'.

¿Por qué? TypeScript estrecha variables, pero no el resultado de una llamada. Que jefe() fuera verdadero en la condición no garantiza nada sobre la siguiente llamada a jefe(). Con as j, en cambio, j es una variable y su tipo es Empleado. Este es el error de plantilla más común al trabajar con señales.

as también funciona en @else if:

import { Component, computed, signal } from '@angular/core';
import { EMPLEADOS } from '../datos';

@Component({
  selector: 'app-condicional',
  template: `
    @for (id of [100, 110, 999]; track id) {
      <button (click)="buscar(id)">Buscar {{ id }}</button>
    }
    <button (click)="cargando.set(true)">Simular carga</button>

    <div>
      @if (cargando()) {
        <p>Cargando...</p>
      } @else if (empleado(); as e) {
        <div class="ficha">
          <strong>{{ e.nombre }} {{ e.apellidos }}</strong>
          <p>{{ e.oficio }}</p>
          @if (e.comision !== null && e.comision > 0) {
            <p>Comisión: {{ e.comision }} €</p>
          } @else {
            <p>Sin comisión</p>
          }
        </div>
      } @else {
        <p>No existe el empleado {{ idBuscado() }}.</p>
      }
    </div>
  `,
})
export class Condicional {
  protected readonly cargando = signal(false);
  protected readonly idBuscado = signal(110);
  protected readonly empleado = computed(
    () => EMPLEADOS.find((e) => e.empId === this.idBuscado()) ?? null,
  );

  protected buscar(id: number) {
    this.cargando.set(false);
    this.idBuscado.set(id);
  }
}

Este ejemplo usa un fichero datos.ts compartido con el resto del módulo. Contiene datos reales de la API EMPRESA:

export interface Empleado {
  empId: number;
  nombre: string;
  apellidos: string;
  oficio: string;
  salario: number;
  comision: number | null;
  deptId: number | null;
}

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

export const EMPLEADOS: Empleado[] = [
  { empId: 100, nombre: 'Ana', apellidos: 'García López', oficio: 'DIRECTOR GENERAL', salario: 6500, comision: null, deptId: 10 },
  { empId: 110, nombre: 'Carmen', apellidos: 'Ruiz Ortega', oficio: 'VENDEDOR', salario: 2200, comision: 500, deptId: 20 },
  { empId: 113, nombre: 'Sergio', apellidos: 'Jiménez Vidal', oficio: 'VENDEDOR', salario: 1900, comision: 0, deptId: 20 },
  { empId: 121, nombre: 'Raquel', apellidos: 'Molina Prieto', oficio: 'PROGRAMADOR', salario: 2800, comision: null, deptId: 30 },
  { empId: 130, nombre: 'Isabel', apellidos: 'Domínguez Vega', oficio: 'CONTABLE', salario: 2600, comision: null, deptId: 40 },
  { empId: 160, nombre: 'Tomás', apellidos: 'Reyes Fuentes', oficio: 'BECARIO', salario: 900, comision: null, deptId: null },
];

export const DEPARTAMENTOS: Departamento[] = [
  { deptId: 10, nombre: 'DIRECCIÓN', localidad: 'Madrid' },
  { deptId: 20, nombre: 'VENTAS', localidad: 'Madrid' },
  { deptId: 30, nombre: 'INFORMÁTICA', localidad: 'Valencia' },
  { deptId: 40, nombre: 'CONTABILIDAD', localidad: 'Sevilla' },
];

Cada botón da un resultado real distinto: 110 pinta «Carmen Ruiz Ortega» con «Comisión: 500 €»; 100 pinta «Ana García López» con «Sin comisión»; 999 pinta «No existe el empleado 999.».

⚠️ Importante: Una condición como @if (e.comision) trata 0 como falso. Sergio tiene comisión 0, y es un dato distinto de null, que significa que no aplica. Cuando el cero sea un valor válido, compara de forma explícita: e.comision !== null.


9. @for: repetir

Sintaxis

@for (e of empleados(); track e.empId) {
  <li>{{ e.nombre }}</li>
} @empty {
  <li>No hay empleados.</li>
}
  • e es la variable local de cada vuelta. Solo existe dentro del bloque.
  • track indica cómo identificar cada elemento. Es obligatorio. Sin él, el compilador se detiene con NG5002: @for loop must have a "track" expression.
  • @empty es opcional y se pinta cuando la colección está vacía.

Variables implícitas

Dentro del bloque tienes seis variables automáticas:

Variable Significado
$index Posición, desde 0
$count Número total de elementos
$first true en el primero
$last true en el último
$even true si el índice es par
$odd true si el índice es impar

Puedes darles otro nombre con let, algo imprescindible en bucles anidados, donde el $index interior tapa al exterior:

import { Component, signal } from '@angular/core';
import { EMPLEADOS, Empleado } from '../datos';

@Component({
  selector: 'app-variables-for',
  styles: `
    tr.par { background: #f5f6fb; }
    tr.primero td { font-weight: bold; }
    tr.ultimo td { border-bottom: 3px solid #283593; }
  `,
  template: `
    <table>
      <tr><th>$index</th><th>Empleado</th><th>$count</th><th>$first</th><th>$last</th><th>$even</th><th>$odd</th></tr>
      @for (e of empleados(); track e.empId; let i = $index, total = $count) {
        <tr [class.par]="$even" [class.primero]="$first" [class.ultimo]="$last">
          <td>{{ i }}</td>
          <td>{{ e.nombre }}</td>
          <td>{{ total }}</td>
          <td>{{ $first }}</td>
          <td>{{ $last }}</td>
          <td>{{ $even }}</td>
          <td>{{ $odd }}</td>
        </tr>
      } @empty {
        <tr><td colspan="7">No hay empleados.</td></tr>
      }
    </table>
    <button (click)="empleados.set([])">Vaciar</button>
    <button (click)="restaurar()">Restaurar</button>

    <h3>Otros iterables</h3>
    <ul>
      @for (par of plantilla(); track par[0]) {
        <li>{{ par[0] }}: {{ par[1] }} personas</li>
      }
      @for (ciudad of ciudades(); track ciudad) {
        <li>{{ ciudad }}</li>
      }
    </ul>
  `,
})
export class VariablesFor {
  protected readonly empleados = signal<Empleado[]>(EMPLEADOS.slice(0, 4));
  protected readonly plantilla = signal(new Map([['VENTAS', 5], ['INFORMÁTICA', 5]]));
  protected readonly ciudades = signal(new Set(['Madrid', 'Valencia', 'Madrid']));

  protected restaurar() {
    this.empleados.set(EMPLEADOS.slice(0, 4));
  }
}

Tabla de cuatro empleados con las columnas de variables implícitas y la primera fila en negrita

Figura 8. Valores reales de las variables implícitas; las filas pares tienen fondo y la última, un borde inferior.

@for recorre cualquier iterable, no solo arrays. La parte inferior del ejemplo muestra VENTAS: 5 personas, INFORMÁTICA: 5 personas, Madrid y Valencia: con un Map cada elemento es un par [clave, valor], y el Set ya había eliminado el Madrid repetido.

Qué hace track y por qué es obligatorio

Cuando cambia la lista, Angular tiene que decidir qué hacer con cada fila: mantenerla, moverla, crearla o destruirla. Tocar el DOM es caro, y destruir una fila también destruye lo que el usuario tenía en ella: el texto de un campo, el foco, la posición del cursor o el estado de un componente hijo.

track da a cada elemento una clave. Angular compara las claves de la lista nueva con las de la anterior:

  • Clave que ya existía: reutiliza los nodos y solo actualiza lo que cambie.
  • Clave nueva: crea los nodos.
  • Clave que ya no aparece: destruye los nodos.

Elegir mal la clave no da error de compilación, pero sí comportamientos raros. Este experimento lo demuestra con tres columnas iguales, salvo por el track:

import { Component, signal } from '@angular/core';
import { EMPLEADOS, Empleado } from '../datos';

@Component({
  selector: 'app-lista-track',
  template: `
    <button (click)="invertir()">Invertir orden</button>
    <button (click)="recargar()">Recargar (objetos nuevos)</button>

    <div class="columnas">
      <section>
        <h3>track e.empId</h3>
        @for (e of lista(); track e.empId) {
          <div class="fila"><span>{{ e.nombre }}</span><input placeholder="nota" /></div>
        }
      </section>
      <section>
        <h3>track $index</h3>
        @for (e of lista(); track $index) {
          <div class="fila"><span>{{ e.nombre }}</span><input placeholder="nota" /></div>
        }
      </section>
      <section>
        <h3>track e</h3>
        @for (e of lista(); track e) {
          <div class="fila"><span>{{ e.nombre }}</span><input placeholder="nota" /></div>
        }
      </section>
    </div>
  `,
})
export class ListaTrack {
  protected readonly lista = signal<Empleado[]>(EMPLEADOS.slice(0, 4));

  protected invertir() {
    this.lista.update((l) => [...l].reverse());
  }

  // Simula una nueva respuesta de la API: mismos datos, objetos distintos
  protected recargar() {
    this.lista.update((l) => l.map((e) => ({ ...e })));
  }
}

El input no está enlazado a ningún dato a propósito: su texto vive solo en el DOM, así que sirve de testigo de qué nodo es cada uno. Con «Revisar» escrito en la fila de Ana de las tres columnas, se pulsa Invertir orden:

Tras invertir, la nota sigue a Ana en track e.empId y en track e, pero se queda en la primera fila en track $index

Figura 9. Tras invertir. Con $index, la nota de Ana aparece junto a Raquel.

Con track $index ocurre un fallo real. La posición 0 sigue siendo la clave 0, así que Angular reutiliza esa fila y solo cambia el texto del nombre. El input, que no sabe nada de nombres, conserva lo escrito. La nota ha pasado a estar junto a otra persona. Imagina esto mismo en una lista de nóminas.

Después se pulsa Recargar, que crea objetos nuevos con los mismos datos, igual que una nueva petición al servidor:

Tras recargar, la nota sobrevive en track e.empId y en track $index, y desaparece en track e

Figura 10. Tras recargar. Con track e, las notas se han perdido.

Para medir qué ocurre de verdad, se marcó cada nodo del DOM antes de cada operación y se contó cuántos seguían vivos después:

Operación track e.empId track $index track e
Invertir: filas reutilizadas 4 de 4 4 de 4 4 de 4
Invertir: dónde queda la nota Con Ana En la fila de Raquel Con Ana
Recargar: filas reutilizadas 4 de 4 4 de 4 0 de 4
Recargar: dónde queda la nota Con Ana En la misma posición Perdida

Con track e la clave es la identidad del objeto, y un objeto recién creado no es igual al anterior aunque tenga los mismos datos. Angular destruye todas las filas y las crea de nuevo. En modo desarrollo te avisa por consola:

NG0956: The configured tracking expression (track by identity) caused re-creation of the entire collection of size 4. This is an expensive operation requiring destruction and subsequent creation of DOM nodes, directives, components etc.

Tres listas comparadas: track por identificador mueve nodos, track por índice cambia textos y track por objeto destruye y recrea

Figura 11. Qué hace @for según la clave.

Clave Cuándo usarla
track e.empId Casi siempre: datos con identificador estable
track ciudad Listas de primitivos sin repetidos
track $index Listas fijas que nunca se reordenan y sin estado en sus filas
track e Objetos que conservan su identidad y nunca se recargan; úsala con cuidado

Claves duplicadas

Si dos elementos tienen la misma clave, Angular no puede distinguirlos. No falla al compilar, pero en modo desarrollo sale un aviso en consola. Con ['VENDEDOR', 'VENDEDOR', 'BECARIO'] y track o, en 22.2 aparece al reordenar la lista, y en 21.2 ya desde el primer pintado:

NG0955: The provided track expression resulted in duplicated keys for a given collection. Adjust the tracking expression such that it uniquely identifies all the items in the collection. Duplicated keys were: key "VENDEDOR" at index "1" and "2".

Si los valores pueden repetirse y no hay identificador, track $index es aquí la opción correcta.


10. @switch: elegir entre varias opciones

@switch compara un valor con varios casos. Conviene cuando hay más de dos ramas que dependen de un mismo valor; para condiciones variadas, usa @if.

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

type EstadoPedido = 'PENDIENTE' | 'ENVIADO' | 'ENTREGADO' | 'CANCELADO';

interface Pedido {
  pedidoId: number;
  fechaPedido: string;
  estado: EstadoPedido;
}

@Component({
  selector: 'app-estado-pedido',
  styles: `
    .chapa { padding: 2px 8px; border-radius: 10px; font-size: 13px; }
    .curso { background: #e8eaf6; color: #283593; }
    .ok { background: #c8e6c9; color: #1b5e20; }
    .mal { background: #ffccbc; color: #bf360c; }
  `,
  template: `
    <table>
      <tr><th>Pedido</th><th>Fecha</th><th>Estado</th></tr>
      @for (p of pedidos(); track p.pedidoId) {
        <tr>
          <td>{{ p.pedidoId }}</td>
          <td>{{ p.fechaPedido }}</td>
          <td>
            @switch (p.estado) {
              @case ('PENDIENTE')
              @case ('ENVIADO') {
                <span class="chapa curso">En curso</span>
              }
              @case ('ENTREGADO') {
                <span class="chapa ok">Entregado</span>
              }
              @case ('CANCELADO') {
                <span class="chapa mal">Cancelado</span>
              }
              @default never;
            }
          </td>
        </tr>
      }
    </table>
  `,
})
export class EstadoPedidoLista {
  protected readonly pedidos = signal<Pedido[]>([
    { pedidoId: 5001, fechaPedido: '2025-01-15', estado: 'ENTREGADO' },
    { pedidoId: 5002, fechaPedido: '2025-01-28', estado: 'ENVIADO' },
    { pedidoId: 5003, fechaPedido: '2025-02-03', estado: 'PENDIENTE' },
    { pedidoId: 5004, fechaPedido: '2025-02-20', estado: 'CANCELADO' },
  ]);
}

Tabla de cuatro pedidos con chapas de colores: entregado en verde, en curso en azul y cancelado en rojo

Figura 12. Los estados PENDIENTE y ENVIADO comparten rama y se muestran como «En curso».

Hay cuatro diferencias con el switch de JavaScript:

  • No hay break. Solo se ejecuta la rama que coincide; no se cae a la siguiente.
  • Varios @case seguidos comparten un bloque. Es lo que hacen PENDIENTE y ENVIADO en el ejemplo.
  • @default es la rama para el resto de valores, con un bloque normal: @default { ... }.
  • @default never; comprueba que no falta ningún caso. Si estado es una unión de literales y olvidas uno, no compila.

La última es muy útil. Si mañana añades 'DEVUELTO' al tipo, cada @switch que lo trate da error, y no te dejas ninguno sin actualizar. Probado sobre un tipo 'A' | 'B' con un solo @case ('A'):

error TS2322: Type '"A" | "B"' is not assignable to type 'never'.

Los casos se comparan con igualdad estricta, y el tipo se comprueba al compilar. Un @case ('1') sobre una señal numérica da TS2678: Type 'string' is not comparable to type 'number'.


11. @let: variables locales

@let declara una variable dentro de la plantilla. Termina en ; y su valor se actualiza cuando cambian los datos que usa.

import { Component, signal } from '@angular/core';
import { EMPLEADOS } from '../datos';

@Component({
  selector: 'app-variables-let',
  template: `
    @let lista = empleados();
    @let masa = lista.reduce((suma, e) => suma + e.salario, 0);
    <p>Masa salarial: {{ masa }} €</p>
    <p>Media: {{ masa / lista.length }} €</p>

    @for (e of lista; track e.empId) {
      @let conComision = e.salario + (e.comision ?? 0);
      <p>{{ e.nombre }}: {{ conComision }} € ({{ (conComision * 100 / masa).toFixed(1) }} %)</p>
    }
    <button (click)="subir()">Subir</button>
  `,
})
export class VariablesLet {
  protected readonly empleados = signal(EMPLEADOS.slice(0, 3));

  protected subir() {
    this.empleados.update((l) => l.map((e) => ({ ...e, salario: e.salario + 100 })));
  }
}

Salida real inicial:

Masa salarial: 10600 €
Media: 3533.3333333333335 €
Ana: 6500 € (61.3 %)
Carmen: 2700 € (25.5 %)
Sergio: 1900 € (17.9 %)

Tras pulsar Subir, los valores se recalculan solos: la masa pasa a 10900 € y Ana a 6600 € (60.6 %).

Las reglas son pocas:

  • Es de solo lectura. @let x = 1; seguido de (click)="x = 2" da NG8015: Cannot assign to @let declaration 'x'.
  • Hay que declararla antes de usarla. Leerla antes da NG8016: Cannot read @let declaration 'doble' before it has been defined.
  • Su ámbito es el bloque donde está. conComision existe en cada vuelta del @for, pero no fuera de él.

Cuándo usarla

@let brilla en tres casos: evitar leer varias veces la misma señal en una plantilla larga, poner nombre a un dato derivado que solo tiene sentido en la vista y calcular algo por cada fila de un @for, donde un computed no llega.

No la uses para meter lógica de negocio en la plantilla. La masa salarial del ejemplo sería mejor como computed en la clase si se reutilizara o tuviera que probarse.


12. Variables de plantilla

Una variable de plantilla da un nombre a un elemento o a un componente para usarlo en otra parte de la misma plantilla. Se declara con #.

import { Component } from '@angular/core';
import { Contador } from './contador';

@Component({
  selector: 'app-variables-ref',
  imports: [Contador],
  template: `
    <input #busqueda placeholder="Buscar" />
    <button (click)="busqueda.focus()">Dar el foco</button>
    <button (click)="busqueda.value = busqueda.value.toUpperCase()">Mayúsculas</button>
    <p>Etiqueta del elemento: {{ busqueda.tagName }}</p>

    <app-contador #c />
    <button (click)="c.reiniciar()">Reiniciar desde el padre</button>
    <p>El contador vale {{ c.valor() }}</p>

    @if (true) {
      <input #interna value="dentro" />
      <span>{{ busqueda.placeholder }} / {{ interna.value }}</span>
    }
  `,
})
export class VariablesRef {}
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-contador',
  template: `<span>{{ valor() }}</span> <button (click)="sumar()">+1</button>`,
})
export class Contador {
  readonly valor = signal(0);

  sumar() {
    this.valor.update((v) => v + 1);
  }

  reiniciar() {
    this.valor.set(0);
  }
}

Lo que referencia la variable depende de dónde la pongas:

Declarada en Referencia a
Un elemento HTML El elemento del DOM, con su tipo: HTMLInputElement
Un componente La instancia de la clase del componente
<ng-template> Un TemplateRef (sección 13)
Una directiva con #x="nombre" La directiva exportada; lo verás en #f="ngForm" en el módulo 16

En el ejemplo, escribir hola y pulsar Mayúsculas deja HOLA, y Dar el foco devuelve el cursor al campo sin una línea de código en la clase. Es perfecto para interacciones entre elementos cercanos. Si la clase necesita acceder al elemento, hay que usar viewChild, que se ve en el módulo 06.

Ámbito

Una variable creada fuera de un bloque se ve dentro, pero no al revés. El span del ejemplo lee busqueda y también interna. Si intentas leer desde fuera una variable declarada dentro de @if:

@if (true) { <input #d /> }
{{ d.value }}
error TS2339: Property 'd' does not exist on type 'VariablesRef'.

Lo mismo sucede con la variable del @for: no existe tras cerrar el bloque.

⚠️ Importante: Con #c solo se accede a lo que permiten los modificadores de la clase hija. Aunque desde Angular 22.2 una plantilla puede leer miembros privados de su propia clase (sección 15), no puede leer los de otro componente.


13. ng-template y ng-container

ng-template: un molde que no se pinta

<ng-template> declara un fragmento de plantilla que no se pinta por sí solo. Angular lo guarda como un molde que se puede instanciar cuando y donde haga falta, las veces que sea.

<section>
  <ng-template><p>Nunca se pinta</p></ng-template>
  <p>Sí se pinta</p>
</section>

El DOM real del section es:

<!--container--><p>Sí se pinta</p>

Solo queda un comentario, que Angular usa como marcador de posición. Esos comentarios <!--container--> también aparecen donde hay un @if o un @for. Los verás en el inspector del navegador, y son normales.

Para pintar el molde se necesita NgTemplateOutlet, una directiva que hay que importar de @angular/common.

import { NgTemplateOutlet } from '@angular/common';
import { Component, input, signal, TemplateRef } from '@angular/core';
import { EMPLEADOS, Empleado } from '../datos';

@Component({
  selector: 'app-lista-generica',
  imports: [NgTemplateOutlet],
  template: `
    <ul>
      @for (e of elementos(); track e.empId) {
        <li>
          <ng-container [ngTemplateOutlet]="fila()" [ngTemplateOutletContext]="{ $implicit: e, posicion: $index + 1 }" />
        </li>
      }
    </ul>
  `,
})
export class ListaGenerica {
  readonly elementos = input.required<Empleado[]>();
  readonly fila = input.required<TemplateRef<unknown>>();
}

@Component({
  selector: 'app-plantillas-reutilizables',
  imports: [NgTemplateOutlet, ListaGenerica],
  template: `
    <button (click)="compacta.set(!compacta())">
      Vista {{ compacta() ? 'de tarjetas' : 'compacta' }}
    </button>

    <ng-template #tarjeta let-emp let-destacar="destacar">
      <div class="tarjeta">
        <h4>{{ emp.nombre }} {{ emp.apellidos }} {{ destacar ? '★' : '' }}</h4>
        {{ emp.oficio }}: {{ emp.salario }} €
      </div>
    </ng-template>

    <ng-template #linea let-emp>
      <div class="compacta">{{ emp.nombre }}: {{ emp.oficio }}</div>
    </ng-template>

    @for (e of empleados; track e.empId) {
      <ng-container
        [ngTemplateOutlet]="compacta() ? linea : tarjeta"
        [ngTemplateOutletContext]="{ $implicit: e, destacar: e.salario > 2500 }" />
    }

    <h3>Plantilla pasada a un hijo</h3>
    <app-lista-generica [elementos]="empleados" [fila]="filaPropia" />
    <ng-template #filaPropia let-emp let-pos="posicion">
      {{ pos }}. <em>{{ emp.apellidos }}</em>, {{ emp.nombre }}
    </ng-template>
  `,
})
export class PlantillasReutilizables {
  protected readonly compacta = signal(false);
  protected readonly empleados = EMPLEADOS.slice(0, 3);
}

Tres tarjetas de empleado, con una estrella en la de Ana, y debajo una lista numerada generada por un componente hijo

Figura 13. El molde tarjeta usado tres veces y un molde pasado al componente hijo.

Una ng-template declara variables con let y dos usos del outlet le pasan distintos contextos

Figura 14. Cada uso aporta su propio contexto de datos.

El contexto es un objeto, y así se conecta con las variables:

  • let-emp, sin valor, recibe la clave especial $implicit.
  • let-destacar="destacar" recibe la clave destacar. El nombre de la izquierda es local; el de la derecha, la clave del contexto.

Al pulsar el botón, [ngTemplateOutlet] cambia de molde y cada fila pasa a verse como Ana: DIRECTOR GENERAL. La misma lista admite dos vistas sin duplicar el @for.

El último bloque va un paso más allá: el padre decide cómo se pinta cada fila y el hijo decide cuándo. La input() que recibe la plantilla se explica en el módulo 06. Con esta técnica se construyen tablas y listas genéricas que no saben nada de los datos que muestran.

⚠️ Importante: Las variables let- de una ng-template usada con NgTemplateOutlet son de tipo any. Un error como {{ emp.noExiste }} compila sin avisar y pinta un hueco. Es un precio que pagas por la flexibilidad, así que no abuses de esta técnica.

ng-container: agrupar sin añadir nodos

<ng-container> agrupa contenido y no genera ningún elemento en el DOM. Es necesario cuando un elemento de más rompería el HTML, como ocurre en un <select>:

import { Component } from '@angular/core';
import { DEPARTAMENTOS, EMPLEADOS } from '../datos';

@Component({
  selector: 'app-contenedor',
  template: `
    <select>
      @for (d of departamentos; track d.deptId) {
        <ng-container>
          <option disabled>--- {{ d.nombre }} ---</option>
          @for (e of empleadosDe(d.deptId); track e.empId) {
            <option [value]="e.empId">{{ e.nombre }}</option>
          }
        </ng-container>
      }
    </select>
  `,
})
export class Contenedor {
  protected readonly departamentos = DEPARTAMENTOS;

  protected empleadosDe(deptId: number) {
    return EMPLEADOS.filter((e) => e.deptId === deptId);
  }
}

Los hijos directos del <select> en el navegador son solo option: --- DIRECCIÓN ---, Ana, --- VENTAS ---, Carmen, Sergio…

Fíjate en que aquí ng-container no hace ninguna falta, porque @for tampoco genera elementos: el bloque podría contener directamente las dos partes. Desde que existe el control de flujo integrado, sus dos usos principales son aplicar una directiva sin añadir nodos, como [ngTemplateOutlet] en la sección anterior, y agrupar contenido para proyectarlo en un componente (módulo 06).

Elemento Se pinta Para qué sirve
<ng-template> No, salvo que se instancie Definir un molde reutilizable
<ng-container> Su contenido, sin elemento propio Aplicar directivas o agrupar sin añadir DOM
@if, @for, @switch Su contenido, sin elemento propio Control de flujo

14. Comentarios y caracteres especiales

Comentarios HTML

Los comentarios de siempre funcionan, y el compilador los elimina del resultado:

<!-- Este comentario no llega al navegador -->

Comentarios entre atributos 🆕 v22.0

🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: borra temporalmente el atributo, o mueve la explicación a un comentario <!-- --> antes de la etiqueta; un // entre atributos da el error NG5002: Opening tag "button" not terminated.

Dentro de una etiqueta se pueden escribir comentarios de línea y de bloque. Es muy cómodo para desactivar un enlace mientras depuras, sin tener que borrarlo:

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

@Component({
  selector: 'app-com',
  template: `
    <button
      // type="submit"
      class="principal"
      /* disabled */
      (click)="guardar()"
    >
      Guardar
    </button>
  `,
})
export class Com {
  guardar() {}
}

Caracteres especiales

La sintaxis de plantillas reserva @, { y }. Cuando quieras escribirlos como texto, usa entidades HTML:

<p>Escribe a ana&#64;empresa.es o usa &#123; llaves &#125;</p>

La arroba suelta se tolera: ana@empresa.es y @ suelto compilan y salen tal cual en las dos versiones, aunque la entidad deja claro lo que quieres. Las llaves, no:

<p>Precio { 10 }</p>
<p>Llave: } suelta</p>
error NG5002: Unexpected character "EOF" (Do you have an unescaped "{" in your template? Use "{{ '{' }}") to escape it.)
error NG5002: Unexpected closing block. The block may have been closed earlier. If you meant to write the `}` character, you should use the "&#125;" HTML entity instead.

ngNonBindable hace que Angular no procese el interior de un elemento, muy útil para documentar la propia sintaxis:

<p ngNonBindable>Esto no se evalúa: {{ 1 + 1 }}</p>

En pantalla sale el texto Esto no se evalúa: {{ 1 + 1 }}, sin calcular.


15. Miembros privados en la plantilla 🆕 v22.2

🆕 Desde Angular 22.2. No existe en la LTS (Angular 21), ni en 22.0 ni en 22.1. Si usas la LTS: declara protected todo miembro que lea la plantilla; con private obtendrás el error TS2341.

El modificador correcto

Hasta ahora todos los ejemplos usan protected para lo que lee la plantilla. El motivo es que una plantilla debe tener acceso, pero el resto de la aplicación no. Es la convención que usa ng new y que seguimos en todo el curso, y lo seguirá siendo porque funciona en todas las versiones.

Modificador Desde la plantilla Desde otras clases
public (por defecto) Sí Sí
protected Sí No
private Sí, desde 22.2 No
#privado No: NG5002 No

Qué cambia en 22.2

Este componente declara todo private:

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

@Component({
  selector: 'app-privados',
  template: `
    <p>{{ nombre() }}: {{ salario }}</p>
    <button (click)="subir()">Subir</button>
  `,
})
export class Privados {
  private readonly nombre = signal('Ana');
  private salario = 1000;

  private subir() {
    this.salario += 100;
  }
}

Este fichero se ha compilado con cuatro versiones, con strictTemplates activo:

Versión Resultado
21.2.25 (LTS) Error
22.1.8 Error
22.2.0 Compila
22.2.4 Compila

El error de 21.2 y 22.1, en cada una de las líneas que tocan un miembro privado, es:

error TS2341: Property 'nombre' is private and only accessible within class 'Privados'.

La novedad, por tanto, empieza exactamente en 22.2.0. No hay ninguna opción que activar.

Sus límites

Solo tu propia clase. La regla no abre los miembros de otros componentes. Si un padre lee un miembro privado del hijo con una variable de plantilla, sigue siendo un error también en 22.2.4:

<app-hijo2 #h /> {{ h.secreto }}
error TS2341: Property 'secreto' is private and only accessible within class 'Hijo2'.

#privado nunca funciona. private es una marca de TypeScript que desaparece al compilar, así que el JavaScript resultante puede leer la propiedad. #secreto es privacidad real de JavaScript: la función de plantilla es externa a la clase y el lenguaje impide leerla. El compilador lo rechaza en ambas versiones:

error NG5002: Parser Error: Private identifiers are not supported. Unexpected private identifier: #secreto at column 2 in [ #secreto ]

noUnusedLocals no entiende la plantilla. Si activas esta opción de TypeScript, que ng new no pone, un miembro private usado solo en la plantilla se considera sin usar. Con el componente anterior y la opción activa, en 22.2.4:

error TS6133: 'nombre' is declared but its value is never read.
error TS6133: 'subir' is declared but its value is never read.

salario no da error porque subir() lo usa en la clase. Con protected este problema no existe, y es otro buen motivo para seguir con él.


16. Caso práctico: panel de empleados

Este componente reúne casi todo el módulo: enlaces de propiedad, clases, eventos con filtros de teclado, variables de plantilla, @let, @for con @empty, @if con as y @switch. Los datos se filtran y ordenan en un computed, y la plantilla solo los presenta.

import { Component, computed, signal } from '@angular/core';
import { DEPARTAMENTOS, EMPLEADOS, Empleado } from '../datos';

type Orden = 'nombre' | 'salario';

@Component({
  selector: 'app-panel-empleados',
  styles: `
    h2 { color: #1a237e; margin: 0 0 12px; }
    .filtros { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
    table { border-collapse: collapse; width: 100%; }
    th, td { border-bottom: 1px solid #c5cae9; padding: 6px 10px; text-align: left; }
    th { background: #e8eaf6; }
    th button { all: unset; cursor: pointer; font-weight: bold; }
    th button.activo { color: #283593; text-decoration: underline; }
    tr.seleccionado { background: #fff3e0; }
    .chapa { background: #e8eaf6; color: #283593; border-radius: 10px; padding: 2px 8px; font-size: 13px; }
    .chapa.sin { background: #eceff1; color: #546e7a; }
    .pie { margin-top: 12px; }
  `,
  template: `
    <h2>Empleados de EMPRESA</h2>

    <div class="filtros">
      <input #caja placeholder="Buscar por nombre" [value]="texto()" (input)="texto.set(caja.value)" (keydown.escape)="texto.set('')" />
      <select #depto (change)="deptId.set(+depto.value)">
        <option value="0">Todos los departamentos</option>
        @for (d of departamentos; track d.deptId) {
          <option [value]="d.deptId">{{ d.nombre }}</option>
        }
      </select>
    </div>

    @let filas = visibles();

    <table>
      <tr>
        <th><button [class.activo]="orden() === 'nombre'" (click)="orden.set('nombre')">Nombre</button></th>
        <th>Oficio</th>
        <th>Departamento</th>
        <th><button [class.activo]="orden() === 'salario'" (click)="orden.set('salario')">Salario</button></th>
      </tr>
      @for (e of filas; track e.empId) {
        <tr [class.seleccionado]="e.empId === seleccionado()" (click)="seleccionado.set(e.empId)">
          <td>{{ e.nombre }} {{ e.apellidos }}</td>
          <td>{{ e.oficio }}</td>
          <td>
            @if (nombreDepartamento(e.deptId); as nombre) {
              <span class="chapa">{{ nombre }}</span>
            } @else {
              <span class="chapa sin">Sin asignar</span>
            }
          </td>
          <td>{{ e.salario.toLocaleString('es-ES') }} €</td>
        </tr>
      } @empty {
        <tr><td colspan="4">Ningún empleado coincide con la búsqueda.</td></tr>
      }
    </table>

    <p class="pie">
      @switch (filas.length) {
        @case (0) { Sin resultados }
        @case (1) { Un empleado }
        @default { {{ filas.length }} empleados }
      }
    </p>
  `,
})
export class PanelEmpleados {
  protected readonly departamentos = DEPARTAMENTOS;
  protected readonly texto = signal('');
  protected readonly deptId = signal(0);
  protected readonly orden = signal<Orden>('nombre');
  protected readonly seleccionado = signal<number | null>(null);

  protected readonly visibles = computed<Empleado[]>(() => {
    const texto = this.texto().toLowerCase();
    const deptId = this.deptId();
    const filtrados = EMPLEADOS.filter(
      (e) =>
        `${e.nombre} ${e.apellidos}`.toLowerCase().includes(texto) &&
        (deptId === 0 || e.deptId === deptId),
    );
    return this.orden() === 'salario'
      ? [...filtrados].sort((a, b) => b.salario - a.salario)
      : [...filtrados].sort((a, b) => a.nombre.localeCompare(b.nombre, 'es'));
  });

  protected nombreDepartamento(deptId: number | null) {
    return DEPARTAMENTOS.find((d) => d.deptId === deptId)?.nombre;
  }
}

Panel con los seis empleados ordenados por nombre y una chapa de departamento en cada fila

Figura 15. Estado inicial. Tomás no tiene departamento y muestra «Sin asignar».

Panel filtrado por VENTAS, ordenado por salario, con la fila de Carmen seleccionada

Figura 16. Filtrado por VENTAS y ordenado por salario, con una fila seleccionada.

Vale la pena fijarse en cuatro decisiones:

  • #caja y #depto leen el valor sin $event. Así la clase no necesita tocar el DOM. El + de +depto.value convierte el texto en número.
  • [...filtrados].sort() copia antes de ordenar. sort modifica el array original, y aquí ese array sería la constante EMPLEADOS.
  • track e.empId conserva cada fila aunque cambien el orden o el filtro.
  • @let filas evita leer la señal tres veces y da nombre a lo que la tabla y el pie comparten.

Al escribir zzz en el buscador, se pinta la fila de @empty y el pie dice Sin resultados.

En este módulo los datos están en una constante. Se cargarán de la API con HttpClient en el módulo 12 y con httpResource en el módulo 13. Esta misma plantilla seguirá sirviendo.


17. Errores frecuentes y depuración

Todos los mensajes siguientes son reales, obtenidos al compilar cada caso. Salen igual en Angular 22.2 y en la 21.2, salvo donde se indica.

Código Mensaje (resumido) Causa habitual Solución
NG5002 Parser Error: Unexpected token Sintaxis no admitida: new, n++ Llévalo a un método
NG5002 @for loop must have a "track" expression Falta track Añade track e.id
NG5002 Unexpected closing block Una llave } suelta en el texto Usa &#125;
NG8001 'app-falta' is not a known element Componente no importado o selector mal escrito Añádelo a imports
NG8002 Can't bind to 'x' since it isn't a known property of 'div' Propiedad inexistente, nombre de atributo o directiva sin importar [attr.x], colSpan o imports
NG8015 Cannot assign to @let declaration Asignación a @let Usa una señal
NG8016 Cannot read @let declaration before it has been defined Uso antes de declararla Muévela arriba
TS2339 Property 'x' does not exist on type 'C' Nombre erróneo, global (Math) o variable fuera de su bloque Corrígelo o expón el dato
TS2531 Object is possibly 'null' Señal leída dos veces en @if Usa @if (s(); as v)
TS2540 Cannot assign to 'total' because it is a read-only property Asignar a un readonly Usa .set() si es señal
TS2341 Property 'x' is private En la LTS, private en la plantilla. En 22.2, private de otro componente Usa protected o un método público

También hay avisos del compilador, que no detienen el build pero casi siempre señalan un fallo real:

<p>{{ contador }}</p>
<p>{{ persona?.nombre }}</p>
<p>{{ persona.nombre ?? 'x' }}</p>
Aviso Significado
NG8109 y NG8117 Olvidaste los paréntesis de la señal: contador debería ser contador(). NG8109 también avisa de empleados.length sobre una señal: compila, pero lee la longitud de la función y no la de la lista
NG8107 ?. sobre algo que nunca es nulo: usa .
NG8102 ?? sobre algo que nunca es nulo: sobra
NG8114 Mezcla de ?? y \|\| sin paréntesis

Y unos últimos fallos que no avisan, y por eso cuestan más de encontrar:

  • (clcik) mal escrito: no da error y nunca se dispara.
  • Un track $index en una lista con estado: compila y falla solo al reordenar.
  • disabled="false", que deshabilita el campo.
  • @if (e.comision) cuando 0 es un valor válido.

Cómo depurar una plantilla

  1. Lee el mensaje de ng build. Da fichero, línea y columna. Si la plantilla va en un fichero aparte, la ruta apunta al .html.
  2. Pinta el dato. <pre>{{ empleado() | json }}</pre>, con JsonPipe en imports, enseña qué estás recibiendo de verdad (módulo 07).
  3. Inspecciona el DOM. Los comentarios <!--container--> son normales. Si falta un nodo, revisa la condición del @if.
  4. Mira la consola. Allí salen los avisos NG0955, NG0956 y los del saneador.
  5. Usa Angular DevTools. La extensión del módulo 01 muestra el árbol de componentes y el valor de sus señales.

18. Ejercicios

Ejercicio 1: Ficha de empleado

Crea un componente que muestre una ficha de los EMPLEADOS de datos.ts. Debe enseñar un círculo con las iniciales, con el nombre como title; nombre, oficio y salario; y un enlace mailto: a nombre@empresa.es. El <article> debe llevar el atributo data-emp-id y la clase con-comision si la comisión es mayor que 0. Un botón Siguiente pasará al siguiente empleado, y volverá al primero tras el último.

Ver solución
import { Component, computed, signal } from '@angular/core';
import { Empleado, EMPLEADOS } from '../datos';

@Component({
  selector: 'app-ej1',
  styles: `
    .ficha { border: 2px solid #c5cae9; border-radius: 8px; padding: 12px; max-width: 360px; }
    .ficha.con-comision { border-color: #ff7043; }
    .avatar { display: inline-grid; place-items: center; width: 48px; height: 48px;
              border-radius: 50%; background: #e8eaf6; color: #283593; font-weight: bold; }
    a { display: block; margin-top: 8px; }
  `,
  template: `
    <article class="ficha" [class.con-comision]="tieneComision()" [attr.data-emp-id]="empleado().empId">
      <span class="avatar" [title]="empleado().nombre">{{ iniciales() }}</span>
      <h3>{{ empleado().nombre }} {{ empleado().apellidos }}</h3>
      <p>{{ empleado().oficio }}: {{ empleado().salario }} € al mes</p>
      <a [href]="'mailto:' + correo()">{{ correo() }}</a>
    </article>
    <button (click)="siguiente()">Siguiente</button>
  `,
})
export class Ej1 {
  private readonly posicion = signal(1);

  protected readonly empleado = computed<Empleado>(() => EMPLEADOS[this.posicion()]);
  protected readonly iniciales = computed(() => {
    const e = this.empleado();
    return e.nombre[0] + e.apellidos[0];
  });
  protected readonly tieneComision = computed(() => (this.empleado().comision ?? 0) > 0);
  protected readonly correo = computed(() => this.empleado().nombre.toLowerCase() + '@empresa.es');

  protected siguiente() {
    this.posicion.update((p) => (p + 1) % EMPLEADOS.length);
  }
}

Al arrancar, en el empleado 110, el article tiene las clases ficha con-comision y data-emp-id="110", y el enlace es mailto:carmen@empresa.es. Tras Siguiente, con Sergio, la clase es solo ficha, porque su comisión es 0. posicion puede ser private porque la plantilla nunca la lee: solo lo hacen los computed. Observa que [attr.data-emp-id] necesita attr., y [title] no.

Ejercicio 2: Barra de presupuesto accesible

Dibuja una barra de progreso del presupuesto del departamento de VENTAS (350 000 €). Empieza con 200 000 € gastados y un botón que sume 50 000 €. La anchura debe ser el porcentaje, con un tope del 100 %. La barra se pondrá naranja si se supera el presupuesto. Debe exponer role="progressbar" con aria-valuenow actualizado y una aria-label dinámica.

Ver solución
import { Component, computed, signal } from '@angular/core';

@Component({
  selector: 'app-ej2',
  styles: `
    .barra { height: 18px; background: #eceff1; border-radius: 9px; overflow: hidden; margin: 8px 0; }
    .relleno { height: 100%; background: #3f51b5; transition: width 0.2s; }
    .relleno.excedido { background: #ff7043; }
  `,
  template: `
    <p>Gastado {{ gastado() }} € de {{ presupuesto }} €</p>
    <div class="barra" role="progressbar"
         aria-valuemin="0"
         aria-valuemax="100"
         [attr.aria-valuenow]="porcentaje()"
         [attr.aria-label]="'Presupuesto gastado: ' + porcentaje() + ' %'">
      <div class="relleno" [class.excedido]="excedido()" [style.width.%]="porcentaje()"></div>
    </div>
    <button (click)="gastar()">Gastar 50 000 €</button>
    <button (click)="gastado.set(0)">Reiniciar</button>
  `,
})
export class Ej2 {
  protected readonly presupuesto = 350000;
  protected readonly gastado = signal(200000);
  protected readonly porcentaje = computed(() =>
    Math.min(100, Math.round((this.gastado() * 100) / this.presupuesto)),
  );
  protected readonly excedido = computed(() => this.gastado() > this.presupuesto);

  protected gastar() {
    this.gastado.update((g) => g + 50000);
  }
}

Tras cuatro pulsaciones se han gastado 400 000 €. En el navegador, aria-valuenow="100", el relleno tiene class="relleno excedido" y style="width: 100%;". Math.min está en la clase porque la plantilla no ve Math. Los atributos fijos, como aria-valuemin, no llevan corchetes.

Ejercicio 3: Disponibilidad de productos

Dada una lista con productoId, nombre, stock y descatalogado, muestra por cada producto: «descatalogado» si lo está, sea cual sea su stock; «agotado» si el stock es 0; «quedan N» si es menor que 5; y «disponible» en el resto de casos.

Ver solución
import { Component, signal } from '@angular/core';

interface Producto {
  productoId: number;
  nombre: string;
  stock: number;
  descatalogado: boolean;
}

@Component({
  selector: 'app-ej3',
  styles: `
    .agotado { color: #bf360c; font-weight: bold; }
    .pocas { color: #e65100; }
    .fuera { color: #78909c; text-decoration: line-through; }
  `,
  template: `
    <ul>
      @for (p of productos(); track p.productoId) {
        <li>
          {{ p.nombre }}:
          @if (p.descatalogado) {
            <span class="fuera">descatalogado</span>
          } @else if (p.stock === 0) {
            <span class="agotado">agotado</span>
          } @else if (p.stock < 5) {
            <span class="pocas">quedan {{ p.stock }}</span>
          } @else {
            <span>disponible</span>
          }
        </li>
      }
    </ul>
  `,
})
export class Ej3 {
  protected readonly productos = signal<Producto[]>([
    { productoId: 1, nombre: 'Portátil UltraBook 14', stock: 25, descatalogado: false },
    { productoId: 5, nombre: 'Torre Workstation', stock: 4, descatalogado: false },
    { productoId: 6, nombre: 'Ratón inalámbrico', stock: 0, descatalogado: false },
    { productoId: 9, nombre: 'Impresora antigua', stock: 3, descatalogado: true },
  ]);
}
Portátil UltraBook 14: disponible
Torre Workstation: quedan 4
Ratón inalámbrico: agotado
Impresora antigua: descatalogado

El orden de las ramas importa. descatalogado va primero porque tiene prioridad sobre el stock; la impresora tiene 3 unidades y no dice «quedan 3». Y p.stock === 0 debe ir antes de p.stock < 5, o «agotado» no se mostraría nunca.

Ejercicio 4: Pedidos con filtro

Muestra una tabla de pedidos con botones para filtrar por estado (TODOS, PENDIENTE, ENVIADO, ENTREGADO, CANCELADO). Cada fila indicará su posición con el formato 1 de 4, tendrá fondo alterno y mostrará la acción pendiente: Preparar envío, Seguir transporte o Ninguna para los dos estados finales. Usa @default never;. Si un filtro no da resultados, muéstralo en una fila.

Ver solución
import { Component, computed, signal } from '@angular/core';

type Estado = 'PENDIENTE' | 'ENVIADO' | 'ENTREGADO' | 'CANCELADO';

interface Pedido {
  pedidoId: number;
  clienteId: number;
  estado: Estado;
}

@Component({
  selector: 'app-ej4',
  styles: `
    tr.impar { background: #f5f6fb; }
    button.activo { outline: 2px solid #283593; }
  `,
  template: `
    @for (f of filtros; track f) {
      <button [class.activo]="filtro() === f" (click)="filtro.set(f)">{{ f }}</button>
    }

    <table>
      <tr><th>#</th><th>Pedido</th><th>Cliente</th><th>Acción</th></tr>
      @for (p of visibles(); track p.pedidoId) {
        <tr [class.impar]="$odd">
          <td>{{ $index + 1 }} de {{ $count }}</td>
          <td>{{ p.pedidoId }}</td>
          <td>{{ p.clienteId }}</td>
          <td>
            @switch (p.estado) {
              @case ('PENDIENTE') { Preparar envío }
              @case ('ENVIADO') { Seguir transporte }
              @case ('ENTREGADO')
              @case ('CANCELADO') { Ninguna }
              @default never;
            }
          </td>
        </tr>
      } @empty {
        <tr><td colspan="4">No hay pedidos con ese estado.</td></tr>
      }
    </table>
  `,
})
export class Ej4 {
  protected readonly filtros = ['TODOS', 'PENDIENTE', 'ENVIADO', 'ENTREGADO', 'CANCELADO'] as const;
  protected readonly filtro = signal<(typeof this.filtros)[number]>('TODOS');
  private readonly pedidos = signal<Pedido[]>([
    { pedidoId: 5001, clienteId: 1001, estado: 'ENTREGADO' },
    { pedidoId: 5002, clienteId: 1002, estado: 'ENVIADO' },
    { pedidoId: 5003, clienteId: 1003, estado: 'PENDIENTE' },
    { pedidoId: 5006, clienteId: 1001, estado: 'ENTREGADO' },
  ]);
  protected readonly visibles = computed(() => {
    const filtro = this.filtro();
    return this.pedidos().filter((p) => filtro === 'TODOS' || p.estado === filtro);
  });
}

Con TODOS, la tabla muestra 1 de 4 y Ninguna, 2 de 4 y Seguir transporte, 3 de 4 y Preparar envío, y 4 de 4 y Ninguna. Con PENDIENTE, una fila, 1 de 1. Con CANCELADO, No hay pedidos con ese estado.. $count cuenta los elementos de la colección filtrada, no los totales. El as const deja filtros con tipos literales, y (typeof this.filtros)[number] obtiene su unión; así el compilador rechaza un filtro inventado.

Ejercicio 5: La casilla que salta de fila

Una lista de tareas añade cada tarea nueva arriba, y usa track $index. El usuario marca la primera casilla, pulsa Nueva tarea y la marca aparece en la tarea recién creada. Explica por qué y corrígelo.

@for (t of tareas(); track $index) {
  <p><label><input type="checkbox" /> {{ t.texto }}</label></p>
}
Ver solución

La casilla es estado del DOM. Con track $index, la fila 0 sigue siendo la clave 0, así que Angular reutiliza sus nodos y solo cambia el texto. La casilla marcada se queda en la fila 0, que ahora muestra otra tarea. La solución es usar un identificador estable:

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

interface Tarea {
  id: number;
  texto: string;
}

@Component({
  selector: 'app-ej5',
  template: `
    <button (click)="alta()">Nueva tarea arriba</button>
    @for (t of tareas(); track t.id) {
      <p><label><input type="checkbox" /> {{ t.texto }}</label></p>
    }
  `,
})
export class Ej5 {
  private siguienteId = 3;
  protected readonly tareas = signal<Tarea[]>([
    { id: 1, texto: 'Revisar nóminas' },
    { id: 2, texto: 'Cerrar trimestre' },
  ]);

  protected alta() {
    const id = this.siguienteId++;
    this.tareas.update((t) => [{ id, texto: `Tarea ${id}` }, ...t]);
  }
}

Tras marcar «Revisar nóminas» y pulsar el botón, el estado medido en el navegador es:

Tarea 3: sin marcar
Revisar nóminas: marcada
Cerrar trimestre: sin marcar

Angular crea solo la fila nueva y conserva las demás con sus nodos. Una mejora posterior sería guardar el estado hecha en los datos, y no en el DOM.

Ejercicio 6: Cabecera reutilizable

Una página tiene dos secciones, Empleados con 24 registros y Clientes con 14, y las dos usan la misma cabecera con título y número de registros. Define la cabecera una sola vez con ng-template y úsala dos veces.

Ver solución
import { NgTemplateOutlet } from '@angular/common';
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-ej6',
  imports: [NgTemplateOutlet],
  styles: `
    .cabecera { display: flex; justify-content: space-between; align-items: center;
                background: #e8eaf6; padding: 8px 12px; border-radius: 6px; }
    .cabecera h3 { margin: 0; color: #1a237e; }
  `,
  template: `
    <ng-template #cabecera let-titulo let-numero="numero">
      <div class="cabecera">
        <h3>{{ titulo }}</h3>
        <span>{{ numero }} registros</span>
      </div>
    </ng-template>

    <ng-container [ngTemplateOutlet]="cabecera"
                  [ngTemplateOutletContext]="{ $implicit: 'Empleados', numero: empleados() }" />
    <p>Contenido de empleados</p>

    <ng-container [ngTemplateOutlet]="cabecera"
                  [ngTemplateOutletContext]="{ $implicit: 'Clientes', numero: clientes() }" />
    <p>Contenido de clientes</p>
  `,
})
export class Ej6 {
  protected readonly empleados = signal(24);
  protected readonly clientes = signal(14);
}

Si la cabecera se va a usar en más de un componente, lo correcto es crear un componente propio. ng-template es adecuada para repeticiones dentro de una misma plantilla.

Ejercicio 7: Importes de un pedido

Pinta las líneas de un pedido con importe por línea, aplicando el descuento en porcentaje, y un pie con base, IVA del 21 % y total. Usa @let y evita escribir más de una vez la fórmula de cada dato. Comprueba tu resultado con dos líneas: un Portátil Gaming 16 a 1499 € sin descuento y tres Ratón inalámbrico a 19,90 € con un 10 %.

Ver solución
import { Component, signal } from '@angular/core';

interface Linea {
  numLinea: number;
  producto: string;
  cantidad: number;
  precioUnitario: number;
  descuento: number;
}

@Component({
  selector: 'app-ej7',
  template: `
    @let lista = lineas();
    @let base = lista.reduce((s, l) => s + l.cantidad * l.precioUnitario * (1 - l.descuento / 100), 0);
    @let iva = base * 0.21;

    <table>
      <thead>
        <tr><th>Producto</th><th>Cantidad</th><th>Precio</th><th>Descuento</th><th>Importe</th></tr>
      </thead>
      <tbody>
        @for (l of lista; track l.numLinea) {
          @let importe = l.cantidad * l.precioUnitario * (1 - l.descuento / 100);
          <tr>
            <td>{{ l.producto }}</td>
            <td>{{ l.cantidad }}</td>
            <td>{{ l.precioUnitario.toFixed(2) }} €</td>
            <td>{{ l.descuento }} %</td>
            <td>{{ importe.toFixed(2) }} €</td>
          </tr>
        }
      </tbody>
      <tfoot>
        <tr><td colspan="4">Base</td><td>{{ base.toFixed(2) }} €</td></tr>
        <tr><td colspan="4">IVA (21 %)</td><td>{{ iva.toFixed(2) }} €</td></tr>
        <tr><td colspan="4">Total</td><td>{{ (base + iva).toFixed(2) }} €</td></tr>
      </tfoot>
    </table>
  `,
})
export class Ej7 {
  protected readonly lineas = signal<Linea[]>([
    { numLinea: 1, producto: 'Portátil Gaming 16', cantidad: 1, precioUnitario: 1499, descuento: 0 },
    { numLinea: 2, producto: 'Ratón inalámbrico', cantidad: 3, precioUnitario: 19.9, descuento: 10 },
  ]);
}

Resultado real: importe de los ratones 53.73 €, base 1552.73 €, IVA 326.07 € y total 1878.80 €.

Aquí la fórmula del importe aún aparece dos veces, una en base y otra en importe. Si la lógica creciera, lo mejor sería un computed que añada el importe a cada línea, de forma que se pueda probar sin la plantilla. Un paso más sería formatear con CurrencyPipe en lugar de toFixed (módulo 07).

Ejercicio 8: Caza de errores

Sin compilar, localiza los seis fallos de este componente. Uno de ellos compila sin error. Después corrígelos.

@Component({
  selector: 'app-ej8',
  template: `
    <p>Total: {{ empleados.length }}</p>
    <p>Redondeo: {{ Math.round(1.6) }}</p>
    @for (e of empleados()) {
      <p [class.caro]="e.salario > 2500" [data-id]="e.empId">{{ e.nombre }}</p>
    }
    @if (jefe()) {
      <p>{{ jefe().nombre }}</p>
    }
    <button (click)="pulsaciones++">{{ pulsaciones() }}</button>
  `,
})
export class Ej8 {
  protected readonly empleados = signal(EMPLEADOS.slice(0, 3));
  protected readonly jefe = signal(EMPLEADOS[0] as (typeof EMPLEADOS)[number] | null);
  protected readonly pulsaciones = signal(0);
}
Ver solución
  1. empleados.length no lee la señal. Falta (). Este es el que compila: solo da el aviso NG8109, pero no muestra el número de empleados.
  2. Math no es visible desde la plantilla: TS2339. Lleva el cálculo a un método.
  3. Falta track en @for: NG5002.
  4. [data-id] no es una propiedad: NG8002: Can't bind to 'data-id'. Usa [attr.data-id].
  5. jefe().nombre puede ser nulo: TS2531. Usa as j.
  6. pulsaciones++ no existe en plantillas: NG5002. Además, una señal se cambia con .set() o .update().
import { Component, signal } from '@angular/core';
import { EMPLEADOS } from '../datos';

@Component({
  selector: 'app-ej8',
  template: `
    <p>Total: {{ empleados().length }}</p>
    <p>Redondeo: {{ redondear(1.6) }}</p>
    @for (e of empleados(); track e.empId) {
      <p [class.caro]="e.salario > 2500" [attr.data-id]="e.empId">{{ e.nombre }}</p>
    }
    @if (jefe(); as j) {
      <p>{{ j.nombre }}</p>
    }
    <button (click)="pulsaciones.update((p) => p + 1)">{{ pulsaciones() }}</button>
  `,
})
export class Ej8 {
  protected readonly empleados = signal(EMPLEADOS.slice(0, 3));
  protected readonly jefe = signal(EMPLEADOS[0] as (typeof EMPLEADOS)[number] | null);
  protected readonly pulsaciones = signal(0);

  protected redondear(n: number) {
    return Math.round(n);
  }
}

Ya corregido, pinta Total: 3, Redondeo: 2, los tres nombres, Ana en el bloque del jefe y un botón que pasa de 0 a 1.

Ejercicio 9 (propuesto): Atajos de teclado

Sobre el panel de la sección 16, haz que Ctrl+F lleve el foco al buscador sin abrir la búsqueda del navegador, y que las flechas arriba y abajo muevan la fila seleccionada. Piensa dónde colocar el listener y cómo acceder al elemento.

Ejercicio 10 (propuesto): Selector agrupado

Reescribe el select de la sección 13 con <optgroup [label]="d.nombre">, muestra solo los departamentos con empleados y añade una opción «Sin departamento» para Tomás. Elige un track adecuado para cada bucle.

Ejercicio 11 (propuesto): private o protected

Coge un componente de este módulo y cambia a private todo lo que lee la plantilla. Compílalo con la versión que tengas, activa noUnusedLocals y anota los errores que aparecen. Después decide, con argumentos, qué modificador usarías en un proyecto que deba compilar en la LTS.


Resumen

Concepto API Idea clave
Plantilla template, templateUrl Se compila a una función con bloques de creación y actualización
Interpolación {{ expresion }} Escribe texto; null no se pinta y el HTML no se interpreta
Expresión ?., ??, !, $any() Sin efectos secundarios; solo ve la clase y la plantilla
Resultado de ?. 🔄 v22.0 a?.b undefined en 22, null en la LTS
Valores derivados computed Se recalcula solo si cambia su dependencia; un método, en cada revisión
Propiedad [propiedad]="valor" Escribe en el objeto DOM, con su tipo y su nombre DOM
Saneado [innerHTML] Interpreta el marcado y elimina lo peligroso
Atributo [attr.x] Para aria-* y data-*; null lo elimina
Clases [class.x], [class] Booleano, objeto o array; conserva las estáticas
Estilos [style.x], [style.x.px], [style.--v] La unidad va en el nombre, salvo en un objeto
Eventos (evento), $event Sentencias; filtros como (keyup.enter)
Condición @if, @else if, @else, as El alias estrecha el tipo de una señal
Repetición @for, track, @empty La clave decide si se reutiliza el DOM
Variables de @for $index, $count, $first, $last, $even, $odd Se renombran con let
Selección @switch, @case, @default never; Sin break; varios casos seguidos; comprueba que no falte ninguno
Variable local @let Solo lectura; declarada antes de usarla
Variable de plantilla #ref Elemento o instancia; ámbito por bloque
Molde ng-template, NgTemplateOutlet, let-x No se pinta hasta instanciarlo; contexto con $implicit
Agrupar ng-container No crea ningún elemento
Comentarios 🆕 v22.0 // y /* */ entre atributos NG5002 en la LTS
Privados 🆕 v22.2 private en la plantilla Solo los de la propia clase; protected funciona en todas las versiones
Sin procesar ngNonBindable, &#64;, &#123; Mostrar la sintaxis reservada como texto