Lección 2 de 415 min
Componentes y plantillas
Bindings, control de flujo con @if y @for, inputs y outputs, pipes y el ciclo de vida de un componente.
Mira la página de una tienda y piensa en piezas: una cabecera con buscador, una rejilla de tarjetas de producto, un botón de cantidad, un resumen del carrito. En Angular, cada una de esas piezas es un componente: una clase con su plantilla y sus estilos, que recibe datos, los muestra y avisa cuando el usuario hace algo.
En esta lección aprenderás a escribir componentes: cómo se enlazan los datos con la vista, cómo se repiten y se ocultan bloques en la plantilla, cómo se comunican padre e hijo, cómo se formatean valores con pipes y qué ocurre desde que un componente nace hasta que se destruye.
Anatomía de un componente y bindings
Un binding es un enlace entre un dato del componente y algo de la plantilla. Angular mantiene ese enlace vivo: cuando el dato cambia, la vista se actualiza, y cuando el usuario actúa, el componente se entera.
Anatomía de un componente
Una clase de TypeScript con @Component: selector, plantilla y estilos.
- Decorador:
- @Component
- Prefijo por defecto:
- app-
Un componente es la pieza básica de una interfaz en Angular: un botón de compra, una tarjeta de producto o una página entera. Es una clase con el decorador @Component, que añade los metadatos que Angular necesita. El selector es la etiqueta con la que lo usas (<app-saludo />), template o templateUrl es su HTML y styles o styleUrl, sus estilos.
Esos estilos están encapsulados: Angular los limita al componente, así que una regla h2 { } no afecta al resto de la página. Las propiedades de la clase son los datos que muestra la plantilla. En el ejemplo, nombre es una señal: un valor que se lee llamándolo como función (nombre()) y que avisa a Angular cuando cambia. Las verás a fondo en el módulo de gestión de estado.
Úsalo cuando
Crea un componente por cada pieza de interfaz con identidad propia o que se repita. Si una plantilla pasa de unas decenas de líneas, probablemente esconde varios componentes.
Ten en cuenta
El prefijo del selector (app- por defecto) evita choques con elementos HTML nativos y con otras librerías. Cuando la plantilla ya no quepa cómodamente en el archivo, pásala a templateUrl.
import { Component, signal } from "@angular/core";
@Component({
selector: "app-saludo",
template: `
<h2>Hola, {{ nombre() }}</h2>
<button (click)="cambiar()">Cambiar</button>
`,
styles: `h2 { color: rebeccapurple; }`,
})
export class Saludo {
nombre = signal("Ana");
cambiar() {
this.nombre.set("Luis");
}
}Interpolación y property binding
Llevar datos del componente a la vista: {{ }} para texto, [ ] para propiedades.
La interpolación {{ expresión }} inserta un valor como texto: {{ producto.nombre }}. El property binding [propiedad]="expresión" asigna un valor a una propiedad de un elemento del DOM o a un input de otro componente: [src]="foto", [disabled]="cargando()".
Los corchetes indican que lo que va entre comillas es una expresión que Angular evalúa, no un texto literal. Hay variantes para atributos, clases y estilos: [attr.aria-label], [class.activa]="seleccionada", [style.width.px]="ancho". Cada vez que el dato cambia, Angular actualiza el DOM.
Úsalo cuando
La interpolación, para texto dentro de las etiquetas; el property binding, para todo lo demás: propiedades, atributos, clases e inputs de componentes hijos.
Ten en cuenta
Sin corchetes, disabled="cargando" asigna el texto «cargando» y el botón queda siempre desactivado. Y mantén las expresiones simples: se evalúan en cada detección de cambios, así que nada de cálculos costosos.
<h3>{{ producto.nombre }} ({{ producto.stock }} en stock)</h3>
<img [src]="producto.foto" [alt]="producto.nombre">
<button [disabled]="producto.stock === 0"
[class.destacado]="producto.oferta"
[attr.aria-label]="'Comprar ' + producto.nombre">
Comprar
</button>Event binding
Llevar lo que ocurre en la vista al componente con (evento).
El event binding (evento)="sentencia" ejecuta código del componente cuando ocurre un evento: (click), (input), (submit) o incluso (keydown.enter), que solo se dispara con esa tecla. La variable especial $event contiene el objeto del evento o, si escuchas a un componente hijo, el valor que emitió.
Angular añade el listener por ti y lo quita al destruir el componente. Además, al manejar el evento marca el componente para revisar su vista, así que no tienes que avisar de nada.
Úsalo cuando
Para responder a cualquier interacción del usuario y para escuchar los outputs de componentes hijos.
Ten en cuenta
Deja la lógica en métodos de la clase, no en la plantilla: (click)="anadir(producto)" se lee y se prueba mejor que tres sentencias encadenadas con punto y coma.
<!-- buscador.html -->
<input (input)="buscar($event)" (keydown.enter)="confirmar()">
// buscador.ts
termino = signal("");
buscar(evento: Event) {
const campo = evento.target as HTMLInputElement;
this.termino.set(campo.value);
}Two-way binding y model()
Sincroniza un valor en los dos sentidos: de la vista al componente y al revés.
El two-way binding combina un property binding y un event binding en una sola sintaxis, [(valor)], conocida como «banana en una caja» por la forma de los paréntesis dentro de los corchetes. El ejemplo clásico es [(ngModel)] en un campo de formulario, que requiere importar FormsModule.
Para que tu propio componente admita [( )], declara la propiedad con model(). Es un input que el hijo también puede escribir: cuando llama a .set(), el valor viaja de vuelta al padre. Si el padre enlaza una señal, se actualiza sola.
Úsalo cuando
En componentes de entrada reutilizables cuyo valor gestiona el padre: un selector de cantidad, un interruptor, un selector de fecha.
Ten en cuenta
Úsalo con moderación: si muchos componentes escriben el mismo dato, cuesta saber quién lo cambió. Para formularios completos, Reactive Forms (que verás en el módulo de formularios) escala mejor que ngModel.
@Component({
selector: "app-selector-cantidad",
template: `
<button (click)="cantidad.set(cantidad() - 1)">−</button>
{{ cantidad() }}
<button (click)="cantidad.set(cantidad() + 1)">+</button>
`,
})
export class SelectorCantidad {
cantidad = model(1);
}
<!-- En el padre, unidades es una señal: se actualiza sola -->
<app-selector-cantidad [(cantidad)]="unidades" />Control de flujo en la plantilla
Las plantillas de Angular tienen bloques que empiezan por @ para decidir qué se pinta. Llegaron en Angular 17 y sustituyen a las directivas *ngIf, *ngFor y ngSwitch, que seguirás viendo en código antiguo. No hace falta importar nada para usarlos.
@if
Muestra u oculta un bloque según una condición, con @else if y @else.
@if (condición) { … } añade el bloque al DOM cuando la condición es verdadera y lo quita cuando es falsa. Puedes encadenar @else if y @else. Con as guardas el resultado en una variable local: @if (usuario(); as u) { Hola, {{ u.nombre }} }.
Úsalo cuando
Para estados que cambian el contenido: cargando, error, lista vacía, usuario con o sin sesión.
Ten en cuenta
Quitar un bloque destruye sus componentes y su estado. Si solo quieres ocultarlo un momento y conservar lo que el usuario escribió, usa [hidden] o una clase CSS.
@if (cargando()) {
<p>Cargando productos…</p>
} @else if (error()) {
<p class="error">No se pudieron cargar.</p>
} @else {
<app-rejilla [productos]="productos()" />
}@for y track
Repite un bloque por cada elemento de una lista; track identifica a cada uno.
@for (p of productos(); track p.id) { … } pinta el bloque una vez por elemento. Dentro tienes variables implícitas como $index, $first, $last, $even y $count, y puedes añadir un bloque @empty para cuando la lista está vacía.
track es obligatorio: le dice a Angular cómo reconocer cada elemento entre una actualización y la siguiente. Si la lista se reordena o llega otra vez del servidor, Angular mueve el DOM de los elementos que ya existían en lugar de destruirlo y crearlo de nuevo, lo que es más rápido y conserva el foco y el estado.
Úsalo cuando
Para cualquier lista: productos, filas de una tabla, mensajes de un chat.
Ten en cuenta
Usa un identificador estable y único, como p.id. track $index solo sirve si la lista no se reordena, y track p (el objeto entero) obliga a recrear todo cuando los objetos llegan nuevos del servidor, aunque traigan los mismos datos.
<ul>
@for (p of productos(); track p.id; let i = $index) {
<li>{{ i + 1 }}. {{ p.nombre }}</li>
} @empty {
<li>No hay productos que coincidan.</li>
}
</ul>@switch
Elige un bloque entre varios según el valor de una expresión.
@switch (expresión) compara el valor con cada @case y muestra el primero que coincide, o el bloque @default si ninguno lo hace. La comparación es estricta (===) y no hay «caída» de un caso al siguiente, así que no necesitas break. Es más legible que una cadena de @else if cuando todas las ramas dependen del mismo valor.
Úsalo cuando
Cuando una sola variable con varios valores posibles decide qué se muestra: el estado de un pedido, el tipo de un mensaje, el paso de un asistente.
Ten en cuenta
Si las condiciones son distintas entre sí (stock() === 0, usuario()?.esAdmin), usa @if. Y añade siempre @default para los valores que no esperabas.
@switch (pedido().estado) {
@case ("pendiente") { <app-aviso-pago /> }
@case ("enviado") { <app-seguimiento [pedido]="pedido()" /> }
@case ("entregado") { <p>Entregado. ¿Qué te pareció?</p> }
@default { <p>Estado desconocido</p> }
}Atención
No confundas @if con ocultar. Cuando la condición pasa a falso, Angular destruye todo lo que hay dentro del bloque: si había un formulario a medio rellenar, se pierde.
Datos entre componentes y pipes
Un componente se comunica con su padre a través de un contrato: los datos bajan por inputs y los avisos suben por outputs. Así cada pieza es independiente y reutilizable.
input()
Datos que un componente recibe de su padre, en forma de señal.
Con input() declaras qué datos acepta un componente desde fuera: precio = input(0), con valor por defecto, o nombre = input.required<string>(), obligatorio. El padre los pasa con property binding: <app-tarjeta [nombre]="p.nombre" [precio]="p.precio" />.
Cada input es una señal de solo lectura: la lees con this.precio() y puedes derivar valores con computed(), que se recalculan solos cuando el input cambia. Sustituye al decorador @Input(), que seguirás viendo en código existente.
Úsalo cuando
Siempre que un componente necesite datos de quien lo usa. Junto con los outputs, los inputs forman el contrato público del componente.
Ten en cuenta
El hijo no puede modificar un input: si necesita cambiar el valor, emite un output o usa model(). Con input.required, olvidar pasarlo es un error de compilación, no un undefined en producción.
@Component({
selector: "app-tarjeta",
template: `<h3>{{ nombre() }}</h3> <p>{{ final() }} €</p>`,
})
export class Tarjeta {
nombre = input.required<string>();
precio = input(0);
descuento = input(0); // 0.2 = 20 %
final = computed(() => this.precio() * (1 - this.descuento()));
}
<!-- En el padre -->
<app-tarjeta [nombre]="p.nombre" [precio]="p.precio" [descuento]="0.2" />output()
Eventos que un componente emite hacia su padre.
Con output() declaras un evento propio: anadido = output<Producto>(). Cuando ocurre, el componente llama a this.anadido.emit(producto) y el padre lo escucha con event binding: (anadido)="alCarrito($event)", donde $event es el valor emitido.
Este flujo en un solo sentido facilita seguir quién cambia qué: el hijo no toca el estado del padre, solo le cuenta lo que pasó y el padre decide. Sustituye al decorador @Output() con EventEmitter.
Úsalo cuando
Cuando el hijo detecta algo que el padre debe decidir: un clic en «Comprar», una fila seleccionada, un formulario enviado.
Ten en cuenta
Nombra el evento por lo que ocurrió (anadido, seleccionado), no por lo que hará el padre (guardarEnCarrito). El hijo no debería saber qué pasa después.
@Component({
selector: "app-tarjeta",
template: `<button (click)="anadido.emit(producto())">Añadir</button>`,
})
export class Tarjeta {
producto = input.required<Producto>();
anadido = output<Producto>();
}
<!-- En el padre -->
<app-tarjeta [producto]="p" (anadido)="alCarrito($event)" />Pipes
Transforman un valor para mostrarlo: fechas, monedas, mayúsculas… o lo que definas tú.
Un pipe se aplica en la plantilla con |: {{ fecha | date: "longDate" }}, {{ precio | currency: "EUR" }}. Angular trae varios integrados: date, currency, number, percent, uppercase, titlecase, slice, json o keyvalue, entre otros. Como cualquier dependencia de la plantilla, se añaden a imports.
Puedes crear los tuyos con @Pipe y un método transform(). Por defecto son puros: Angular solo los vuelve a ejecutar cuando cambia el valor de entrada (o su referencia, si es un objeto), lo que los hace muy baratos.
Úsalo cuando
Para formatear datos en la vista sin ensuciar el componente, y para transformaciones que repites en muchas plantillas.
Ten en cuenta
Los pipes de fecha, número y moneda usan la configuración regional (locale) de la app; para formatos en español tienes que registrar sus datos y configurar LOCALE_ID. Y un pipe puro no se entera si añades un elemento a un array existente: crea un array nuevo.
@Pipe({ name: "iniciales" })
export class InicialesPipe implements PipeTransform {
transform(nombre: string): string {
return nombre
.split(" ")
.map((parte) => parte.charAt(0).toUpperCase())
.join("");
}
}
<!-- Con DatePipe, CurrencyPipe e InicialesPipe en imports -->
<p>{{ cliente.nombre | iniciales }}</p>
<p>{{ pedido.fecha | date: "shortDate" }}: {{ pedido.total | currency: "EUR" }}</p>El pipe async
Se suscribe a un Observable o a una Promise y pinta su último valor.
{{ avisos$ | async }} se suscribe al Observable, muestra cada valor que llega y se desuscribe solo cuando el componente se destruye. Así evitas fugas de memoria y no escribes subscribe() ni unsubscribe() a mano. Además, marca el componente para revisión cada vez que llega un valor.
Es el puente clásico entre RxJS, que verás en el módulo de gestión de estado, y las plantillas. Otra opción muy habitual hoy es convertir el Observable en señal con toSignal() y leerlo como cualquier otra señal.
Úsalo cuando
Cuando tienes un Observable, como la respuesta de HttpClient, y solo necesitas mostrar su valor en la plantilla.
Ten en cuenta
Cada | async crea su propia suscripción: si usas tres veces el mismo Observable de HttpClient, harás tres peticiones. Guarda el valor una sola vez con @if (pedidos$ | async; as pedidos).
// pedidos.ts
export class Pedidos {
private http = inject(HttpClient);
pedidos$ = this.http.get<Pedido[]>("/api/pedidos");
}
<!-- pedidos.html (con AsyncPipe en imports) -->
@if (pedidos$ | async; as pedidos) {
@for (p of pedidos; track p.id) {
<app-fila-pedido [pedido]="p" />
}
} @else {
<p>Cargando…</p>
}El ciclo de vida de un componente
Angular crea cada componente, lo actualiza mientras está en pantalla y lo destruye cuando sale, por ejemplo al cambiar de ruta o cuando un @if pasa a falso. Puedes engancharte a esos momentos.
ngOnInit y los hooks del ciclo de vida
Métodos que Angular llama en momentos concretos de la vida de un componente.
Si la clase define ciertos métodos, los hooks del ciclo de vida, Angular los llama en cada fase: ngOnChanges cuando cambian los inputs, ngOnInit al inicializar, ngAfterViewInit cuando la vista está creada, ngOnDestroy al destruir, entre otros.
El más usado es ngOnInit: se ejecuta una vez, después de que Angular haya asignado los inputs por primera vez. Es el sitio para el trabajo de arranque que depende de ellos, como cargar el producto cuyo id recibes. En el constructor los inputs todavía no tienen valor.
Úsalo cuando
Para la inicialización que depende de los inputs, como lanzar la primera petición con el id que te pasan.
Ten en cuenta
Con inputs de tipo señal, varios hooks pierden protagonismo: en lugar de reaccionar en ngOnChanges, deriva valores con computed(). Escribe implements OnInit para que TypeScript detecte una errata en el nombre del método.
export class DetalleProducto implements OnInit {
id = input.required<string>();
producto = signal<Producto | null>(null);
private catalogo = inject(Catalogo);
ngOnInit() {
// Aquí id() ya tiene el valor que pasó el padre
this.catalogo
.obtener(this.id())
.subscribe((p) => this.producto.set(p));
}
}ngOnDestroy y DestroyRef
Limpiar lo que abriste: temporizadores, suscripciones, listeners globales.
Cuando un componente sale de la pantalla, Angular llama a ngOnDestroy. Es el momento de cancelar lo que el componente puso en marcha y que Angular no gestiona por ti: un setInterval, una suscripción a un Observable que nunca termina, un listener en window. Si no lo haces, siguen vivos y consumiendo memoria.
DestroyRef hace lo mismo sin implementar ninguna interfaz: inject(DestroyRef).onDestroy(() => …) registra la limpieza justo al lado del código que la necesita, y funciona también en servicios. Para RxJS existe takeUntilDestroyed(), que corta la suscripción automáticamente.
Úsalo cuando
Siempre que abras algo de larga duración. En código nuevo, DestroyRef y takeUntilDestroyed() dejan la limpieza junto a lo que limpian.
Ten en cuenta
Las peticiones de HttpClient terminan solas tras la respuesta y el pipe async se desuscribe por ti. Lo que no termina solo son los intervalos, los WebSockets y los Observables de eventos.
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
export class Reloj {
hora = signal(new Date());
constructor() {
const id = setInterval(() => this.hora.set(new Date()), 1000);
inject(DestroyRef).onDestroy(() => clearInterval(id));
// Con RxJS: se desuscribe al destruir el componente
inject(Avisos).nuevos$
.pipe(takeUntilDestroyed())
.subscribe((aviso) => console.log(aviso));
}
}afterNextRender
Ejecuta código una vez, en el navegador, justo después de que Angular pinte el DOM.
Algunas tareas necesitan el DOM ya pintado: medir un elemento, darle el foco, arrancar una librería de gráficos o de mapas. afterNextRender(() => …) registra una función que Angular ejecuta una sola vez, tras el siguiente renderizado. Su hermana afterEveryRender se ejecuta después de cada uno.
Una ventaja clave: estas funciones no se ejecutan en el servidor. Si tu app usa renderizado en el servidor (SSR), allí no existen window ni el DOM real, y el código que los toca en el constructor o en ngOnInit falla. Dentro de afterNextRender estás a salvo.
Úsalo cuando
Para integrar librerías de terceros que manipulan el DOM y para cualquier código que solo tenga sentido en el navegador.
Ten en cuenta
No lo uses para cambiar los datos que se muestran: si modificas el estado justo después de pintar, provocas otra pasada de renderizado. Regístralo en un contexto de inyección, normalmente el constructor.
@Component({
selector: "app-grafico-ventas",
template: `<canvas #lienzo></canvas>`,
})
export class GraficoVentas {
lienzo = viewChild.required<ElementRef<HTMLCanvasElement>>("lienzo");
constructor() {
afterNextRender(() => {
// Solo en el navegador y con el <canvas> ya en el DOM
new Chart(this.lienzo().nativeElement, configuracion);
});
}
}Interactivo
Las cuatro formas de enlazar datos
Activa o desactiva opciones para compararlas cara a cara.
| Característica | Interpolación | Property binding | Event binding |
|---|---|---|---|
| Sintaxis | {{ expresión }} | [propiedad]="expresión" | (evento)="sentencia" |
| Dirección del dato | Componente → vista | Componente → vista | Vista → componente |
| Ejemplo | {{ producto.nombre }} | [disabled]="cargando()" | (click)="anadir()" |
| Para qué | Mostrar texto | Propiedades, atributos, clases e inputs | Reaccionar a eventos y outputs |
Ponlo en práctica
Saber qué es cada cosa es la mitad del trabajo. La otra mitad es reconocer cuándo usarla.
Interactivo
¿Qué usarías?
Lee cada situación y elige la opción que mejor la resuelve. Fallar aquí no cuenta: es para practicar.
La tarjeta de producto debe mostrar la etiqueta «Agotado» solo cuando el stock llega a cero.
Para recordar
- Un componente es una clase con
@Component: un selector, una plantilla y estilos encapsulados. {{ }}y[ ]llevan datos del componente a la vista,( )lleva eventos de la vista al componente y[( )]hace las dos cosas.@if,@fory@switchson el control de flujo de las plantillas; en@for,trackes obligatorio y decide qué se reutiliza.input()youtput()son el contrato de un componente: datos que entran como señales y eventos que salen.- Limpia lo que abras con
DestroyRefongOnDestroy, y toca el DOM enafterNextRender, no en el constructor.
Comprueba lo aprendido
Cuestionario
Responde las 5 preguntas para completar la lección.
1.En
<img [src]="foto">, ¿qué esfoto?2.¿Por qué
@forexigetrack?3.Un componente hijo declara
cantidad = model(1). ¿Cómo enlaza el padre ese valor en los dos sentidos?4.¿Cómo lee un componente el valor de
nombre = input<string>()?5.Necesitas medir el tamaño de un elemento justo después de pintarlo, y la app también se renderiza en el servidor. ¿Dónde pones ese código?