Un mail cuando publico algo nuevo. Sin spam y te podés dar de baja cuando quieras.
Con un componente ya armado -selector, template, styles, host bindings- lo que falta es que muestre datos reales y reaccione a lo que hace quien lo usa. En el post anterior (primer-componente-angular) quedaron las cuatro piezas del decorador; este post cubre lo que va adentro del template: cómo entra un dato de la clase al HTML, y cómo ese HTML decide qué mostrar.
Interpolación: el dato más simple
La forma más básica de mostrar un valor es interpolarlo entre dobles llaves:
perfil.ts
import { Component } from "@angular/core";@Component({ selector: "app-perfil", template: `<h2>{{ nombre }}</h2> <p>Tareas pendientes: {{ pendientes }}</p>`,})export class Perfil { nombre = "Fernando"; tareas = [{ hecha: false }, { hecha: true }, { hecha: false }]; get pendientes() { return this.tareas.filter((t) => !t.hecha).length; }}
{{ pendientes }} no es solo una propiedad: es cualquier expresión de template válida, incluida la llamada a un getter. Lo que no entra ahí es JavaScript completo: la sintaxis de template prohíbe explícitamente los operadores bitwise (&, |=, ~...), el operador coma, new, y cualquier declaración (variables, funciones, arrow functions, clases). Tampoco hay globales de JavaScript disponibles más allá de undefined y $any: nada de Number, parseInt ni Math directo en el template. La razón es a propósito: una expresión de template tiene que poder leerse de un vistazo, no esconder lógica.
Property binding: no es lo mismo que un atributo HTML
Para pasarle un valor a una propiedad del DOM (o a un input de otro componente), se usan corchetes:
Acá está el gotcha clásico: [disabled]="sinTareas" enlaza la propiedaddisabled del elemento DOM, no el atributo HTML. Son cosas distintas: escribir disabled="false" como atributo plano deja el botón deshabilitado igual, porque HTML solo mira si el atributo está presente, no su valor de texto. [disabled]="sinTareas" sí evalúa la expresión y la asigna como booleano real. La guía de binding llama justamente a esta distinción "la fuente más común de bugs" para quien recién empieza con Angular.
Cuando no hay una propiedad DOM equivalente (atributos ARIA, colspan, atributos de SVG), hace falta el prefijo attr.:
lista.html
<ul [attr.aria-busy]="cargando"></ul>
Class y style binding
Para una clase condicional puntual, [class.nombre]:
(keyup.enter) es un modificador de tecla: Angular filtra el evento y solo dispara el handler cuando la tecla coincide, sin if (event.key === 'Enter') a mano. La guía de eventos recomienda además llamar siempre event.preventDefault() de forma explícita cuando hace falta, en vez de devolver false desde el handler: la intención queda más clara leyendo el código.
De pasada, el binding en dos direcciones: [(ngModel)]="nuevaTarea" (la sintaxis de "caja de banana", corchetes envolviendo paréntesis) ata un input a una propiedad en ambas direcciones a la vez, pero necesita importar FormsModule en el imports del propio componente standalone. Es formularios, y formularios tiene su post dedicado más adelante en la serie (claude/temario-angular.md): acá alcanza con saber que existe y que combina property binding y event binding en una sola sintaxis.
El nuevo control de flujo
Hasta acá, todo lo anterior también existía antes de los componentes standalone. Lo que sí cambió recientemente es cómo se decide qué renderizar. La forma clásica usa directivas estructurales con el asterisco (*ngIf, *ngFor, *ngSwitch), que Angular expande por debajo a un <ng-template>. Necesitan importarse -NgIf, NgFor, NgSwitch desde @angular/common, o el paquete completo CommonModule- en el imports del componente:
lista-tareas.ts (sintaxis clásica)
import { Component } from "@angular/core";import { NgFor, NgIf } from "@angular/common";@Component({ selector: "app-lista-tareas", imports: [NgFor, NgIf], templateUrl: "./lista-tareas.html",})export class ListaTareas { tareas = [{ id: 1, texto: "Escribir el post 3", hecha: false }];}
lista-tareas.html (sintaxis clásica)
<ul> <li *ngFor="let tarea of tareas; trackBy: porId">{{ tarea.texto }}</li></ul><p *ngIf="tareas.length === 0">No hay tareas todavía.</p>
Con la función porId declarada aparte en la clase. Desde la versión 17 (en developer preview) y estable desde la versión 18, Angular tiene una sintaxis de bloques equivalente, incorporada al compilador: no es una directiva, así que no va en ningún imports.
lista-tareas.html (con @for y @empty)
<ul> @for (tarea of tareas; track tarea.id) { <li>{{ tarea.texto }}</li> } @empty { <li>No hay tareas todavía.</li> }</ul>
Dos cosas quedan resueltas en un solo bloque: la iteración y el caso vacío, que antes eran dos directivas independientes que había que mantener sincronizadas a mano.
@if, con @else if y @else
estado.html
@if (pendientes === 0) {<p>No queda nada por hacer.</p>} @else if (pendientes === 1) {<p>Queda una tarea.</p>} @else {<p>Quedan {{ pendientes }} tareas.</p>}
También admite guardar el resultado de la condición en una variable con as, para no repetir una expresión larga adentro del bloque.
@for, con track obligatorio
track no es opcional como era trackBy en *ngFor: escribir un @for sin track es un error de compilación (NG5002: @for loop must have a "track" expression). Angular lo usa para saber qué elemento del DOM corresponde a qué dato cuando la lista cambia, en vez de volver a pintar todo:
lista-tareas.html (variables de contexto)
@for (tarea of tareas; track tarea.id; let i = $index) {<li>{{ i + 1 }}. {{ tarea.texto }}</li>}
Lo ideal para track es un identificador único y estable (tarea.id); $index sirve para listas que no cambian de orden; usar el objeto entero como último recurso funciona, pero pierde la optimización. Además de $index, adentro del bloque están disponibles $count, $first, $last, $even y $odd.
@switch, con comparación estricta
filtro.html
@switch (vista) { @case ('pendientes') {<p>Mostrando solo las pendientes.</p>} @case ('completadas') {<p>Mostrando solo las completadas.</p>} @default {<p>Mostrando todas.</p>} }
Compara con ===, así que vista y cada @case tienen que coincidir en tipo exacto. A diferencia del switch de JavaScript, no hay fallthrough entre casos: no hace falta (ni existe) un break.
Sintaxis vieja vs. nueva, lado a lado
Qué hace
Sintaxis clásica
Sintaxis nueva
Condicional simple
*ngIf="cond"
@if (cond) { ... }
Condicional con alternativa
*ngIf="cond; else otro" + <ng-template #otro>
@if (cond) { ... } @else { ... }
Iterar una lista
*ngFor="let x of xs; trackBy: fn"
@for (x of xs; track x.id) { ... }
Lista vacía
segundo *ngIf="xs.length === 0" aparte
@empty { ... } en el mismo bloque
Múltiples casos
[ngSwitch] + *ngSwitchCase/*ngSwitchDefault
@switch + @case/@default
Hace falta importar algo
Sí: NgIf/NgFor/NgSwitch o CommonModule
No, es sintaxis del compilador
Vigencia: qué versión hace qué
La sintaxis de bloques llegó en developer preview en la versión 17 y pasó a estable en la versión 18. Las directivas estructurales clásicas (*ngIf, *ngFor, *ngSwitch y compañía) siguen funcionando -no van a romper un proyecto viejo de un día para el otro-, pero quedaron deprecadas desde la versión 20: emiten un warning y están planeadas para removerse en una versión futura. Para migrar un proyecto entero de una sola vez existe un schematic oficial:
terminal
ng generate @angular/core:control-flow
Si un tutorial o un proyecto viejo muestra *ngIf/*ngFor, no está roto: es la sintaxis anterior a la 17, y todavía anda. Pero para código nuevo no hay razón para elegirla sobre los bloques.
El dato entra al template con bindings; qué parte del template existe en cada momento lo deciden los bloques de control de flujo. Son dos preguntas distintas -"¿qué valor tiene esto?" contra "¿esto está?"- y Angular las resuelve con dos sintaxis separadas a propósito.
Con esto ya se puede armar una vista real con datos que cambian. Lo que falta para que esos cambios se disparen solos, sin pensar en cuándo releer una propiedad, son los signals: el modelo de reactividad que ocupa el próximo post de la serie.