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),$eventy los filtros de teclado - Controlar el flujo con
@if,@forcontrack,@switchy@let - Elegir la clave de
trackcorrecta 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,NgTemplateOutletyng-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
signales 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 concomputed, 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.
ctxes la instancia del componente. Todo lo que escribes en una expresión se busca ahí.titulo()significactx.titulo(). No hay acceso a variables globales.
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.
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>');
}

Figura 3. Resultado real de cada tipo de valor.
Fíjate en cuatro detalles que suelen sorprender:
nullyundefinedno se pintan. Aparece una cadena vacía, no la palabranull. Los corchetes del ejemplo están puestos a mano para que se note el hueco.- Un objeto muestra
[object Object]. Es lo que devuelve sutoString(). Hay que pintar sus propiedades, o usar el pipejsonpara 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$anyes un error de tipos que has decidido no ver.
Resultado de ?. 🔄 v22.0
🔄 Cambia en Angular 22.0. En la LTS (Angular 21),
a?.ben una plantilla danullcuandoaes nulo; en 22 daundefined, 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
changeDetectionusa OnPush; en la LTS (Angular 21) el valor por defecto esEager, 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, ycomputedse 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">:
- El atributo
value="Ana"forma parte del HTML. Es texto y representa el valor inicial. - El navegador crea un objeto
HTMLInputElement. Sus propiedades, comoinput.valueoinput.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 = ....
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>',
);
}

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 valornullelimina el atributo. No quedatitle=""nititle="null". Es la forma limpia de quitar un atributo condicionalmente.- Los atributos estáticos, como
colspan="2"en eltdodata-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',
}));
}

Figura 6. Estado tras pulsar cuatro veces +10 %: la segunda tarjeta se destaca y la barra cambia de color mediante una variable CSS.
⚠️ Importante:
NgClassyNgStyleson 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 esNG8002: 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');
}
}

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
@fordel 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*ngFory[ngSwitch]. Son directivas estructurales que@if,@fory@switchsustituyen. 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)trata0como falso. Sergio tiene comisión0, y es un dato distinto denull, 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>
}
ees la variable local de cada vuelta. Solo existe dentro del bloque.trackindica cómo identificar cada elemento. Es obligatorio. Sin él, el compilador se detiene conNG5002: @for loop must have a "track" expression.@emptyes 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));
}
}

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:

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:

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.
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' },
]);
}

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
@caseseguidos comparten un bloque. Es lo que hacenPENDIENTEyENVIADOen el ejemplo. @defaultes la rama para el resto de valores, con un bloque normal:@default { ... }.@default never;comprueba que no falta ningún caso. Siestadoes 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"daNG8015: 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á.
conComisionexiste 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
#csolo 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);
}

Figura 13. El molde tarjeta usado tres veces y un molde pasado al componente hijo.
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 clavedestacar. 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 unang-templateusada conNgTemplateOutletson de tipoany. 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 errorNG5002: 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@empresa.es o usa { llaves }</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 "}" 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
protectedtodo miembro que lea la plantilla; conprivateobtendrás el errorTS2341.
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;
}
}

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

Figura 16. Filtrado por VENTAS y ordenado por salario, con una fila seleccionada.
Vale la pena fijarse en cuatro decisiones:
#cajay#deptoleen el valor sin$event. Así la clase no necesita tocar el DOM. El+de+depto.valueconvierte el texto en número.[...filtrados].sort()copia antes de ordenar.sortmodifica el array original, y aquí ese array sería la constanteEMPLEADOS.track e.empIdconserva cada fila aunque cambien el orden o el filtro.@let filasevita 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 } |
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 $indexen una lista con estado: compila y falla solo al reordenar. disabled="false", que deshabilita el campo.@if (e.comision)cuando0es un valor válido.
Cómo depurar una plantilla
- 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. - Pinta el dato.
<pre>{{ empleado() | json }}</pre>, conJsonPipeenimports, enseña qué estás recibiendo de verdad (módulo 07). - Inspecciona el DOM. Los comentarios
<!--container-->son normales. Si falta un nodo, revisa la condición del@if. - Mira la consola. Allí salen los avisos
NG0955,NG0956y los del saneador. - 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
empleados.lengthno lee la señal. Falta(). Este es el que compila: solo da el avisoNG8109, pero no muestra el número de empleados.Mathno es visible desde la plantilla:TS2339. Lleva el cálculo a un método.- Falta
tracken@for:NG5002. [data-id]no es una propiedad:NG8002: Can't bind to 'data-id'. Usa[attr.data-id].jefe().nombrepuede ser nulo:TS2531. Usaas j.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, @, { | Mostrar la sintaxis reservada como texto |