Has visto una librería sin opiniones (React), un framework progresivo que se adopta por capas (Vue) y, antes de los dos, tu propia aplicación en JavaScript puro donde todas las decisiones eran tuyas. Angular está en el otro extremo del eje que 10-01 llamó «librería frente a framework»: es una plataforma completa, con enrutador, cliente HTTP, sistema de formularios, inyección de dependencias, entorno de pruebas, generador de código y renderizado en servidor incluidos y mantenidos por el mismo equipo. Y con TypeScript no como opción sino como requisito de hecho. Eso lo hace el más costoso de aprender de los tres y, a la vez, el más previsible: dos proyectos Angular de empresas distintas se parecen mucho más entre sí que dos proyectos React. En esta lección verás qué significa «con opiniones» cuando se lleva hasta el final; lo mínimo de TypeScript para leer el código —tipos, interfaces y decoradores—, remitiendo a 11-07 para aprenderlo en serio; la CLI y la estructura de un proyecto; los componentes independientes (standalone) con sus imports, que hoy son la forma recomendada; las cuatro formas de enlace de datos y la sintaxis de control de flujo actual (@if, @for con su track obligatorio —el mismo data-id de siempre—, @switch y @defer); las señales (signal, computed, effect, y las entradas y salidas basadas en señales) como modelo de reactividad actual de Angular, comparadas con los ref y computed de Vue, con una mención de Zone.js y de la tendencia zoneless; la inyección de dependencias, que es la pieza que más diferencia a Angular y que ya practicaste sin saberlo en 08-04; HttpClient y lo justo de RxJS —qué es un flujo, subscribe, pipe, el async de la plantilla y por qué hay que darse de baja— aplicado a tu listarTareas de 07-02; el enrutador con carga perezosa, retomando 09-05; los formularios reactivos con validadores que reutilizan tus reglas R1–R10; las pruebas con TestBed y una nota sobre SSR. Y al final, la misma lista de tareas con filtro, por cuarta vez.
Contenido
- Angular como plataforma completa
- Qué significa «con opiniones» llevado hasta el final
- TypeScript: lo mínimo para leer esta lección
- Decoradores: qué son y por qué los usa Angular
- La CLI y la estructura de un proyecto
- Componentes independientes (
standalone) - Plantilla y estilos: las tres formas de escribirlos
- Enlace de datos en sus cuatro formas
- La sintaxis de control de flujo actual
@fory sutrackobligatorio@defer: carga perezosa declarativa- Señales:
signal,computedyeffect - Entradas y salidas basadas en señales
- Zone.js, la detección de cambios y la tendencia zoneless
- Señales de Angular frente a
refde Vue - Inyección de dependencias: servicios e
inject() - Por qué la inyección de dependencias diferencia a Angular
- El parecido con lo que hiciste en 08-04
HttpClienty RxJS: lo justo- El
asyncde la plantilla y por qué hay que darse de baja - Cuándo señales y cuándo observables
- El enrutador con carga perezosa
- Formularios reactivos frente a formularios de plantilla
- Validadores con las reglas R1–R10
- Pruebas con TestBed
- Una nota sobre SSR
- Nómada Tareas en Angular: la lista completa
- Comparación con las tres versiones anteriores
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Angular como plataforma completa
La diferencia empieza en el inventario. Esto viene incluido, es oficial y se actualiza a la vez:
| Necesidad | Angular | React | Vue |
|---|---|---|---|
| Componentes y reactividad | Incluido | Incluido | Incluido |
| Enrutado | Incluido (Angular Router) | Se elige | Oficial aparte (Vue Router) |
| Peticiones HTTP | Incluido (HttpClient) |
Se elige | Se elige |
| Formularios y validación | Incluido (dos sistemas) | Se elige | Se elige |
| Inyección de dependencias | Incluido | No existe | No existe |
| Programación asíncrona avanzada | Incluido (RxJS) | Se elige | Se elige |
| Generación de código | Incluido (ng generate) |
No | Parcial |
| Entorno de pruebas | Incluido (TestBed) | Se elige | Oficial aparte |
| Internacionalización | Incluido | Se elige | Se elige |
| Renderizado en servidor | Incluido | Meta-framework | Meta-framework (Nuxt) |
| Actualizaciones automáticas de código | Incluido (ng update) |
No | Parcial |
| Lenguaje | TypeScript | Ambos | Ambos |
Esa última fila de ng update merece atención porque es una consecuencia poco comentada de tener todo bajo el mismo techo: cuando Angular cambia un API, publica migraciones automáticas que reescriben tu código. Actualizar un proyecto Angular grande suele ser ejecutar un comando y revisar el resultado. En un proyecto React con veinte dependencias de terceros, cada una se actualiza a su ritmo y las incompatibilidades son tuyas. Es una ventaja real de la integración vertical, y es la contrapartida directa del coste 5 de 10-01, la rotación del ecosistema.
- Qué significa «con opiniones» llevado hasta el final
En la práctica, «con opiniones» significa cinco cosas concretas:
- No eliges herramientas, eliges Angular. No hay decisión que tomar sobre enrutador, cliente HTTP o sistema de formularios. Eso elimina la fatiga de decisión y elimina también la posibilidad de usar algo mejor para tu caso.
- La estructura del proyecto viene dada.
ng generate component tarjeta-tareacrea cuatro ficheros con nombres, ubicación y contenido predecibles. Todos los proyectos se parecen. - Hay una forma correcta de hacer cada cosa, documentada, y el resto de la comunidad la sigue.
- La verbosidad es deliberada. Angular prefiere explícito y largo antes que conciso y mágico. Verás más líneas para hacer lo mismo, y esas líneas dicen exactamente qué ocurre.
- La curva es más empinada al principio y más plana después. Hay más conceptos que aprender antes de ser productivo (inyección de dependencias, decoradores, observables), y menos sorpresas una vez aprendidos.
El perfil donde Angular brilla es reconocible: aplicaciones grandes, de larga vida, con equipos numerosos y rotación de personal, típicamente internas o corporativas. Y el perfil donde estorba también: un prototipo, un widget, una web pequeña, una persona sola con prisa.
- TypeScript: lo mínimo para leer esta lección
Angular está escrito en TypeScript y su documentación, sus ejemplos y sus herramientas lo dan por hecho. Se puede usar con JavaScript, pero nadie lo hace y el ecosistema no lo acompaña. Así que necesitas lo mínimo para leer el código. Esto no es un curso de TypeScript: en 11-07 verás qué es, por qué ha ganado y cómo aprenderlo en serio. Aquí van cuatro ideas.
TypeScript es JavaScript con anotaciones de tipo. Todo JavaScript válido es TypeScript válido. Las anotaciones se comprueban al compilar y desaparecen del resultado: en el navegador se ejecuta JavaScript normal.
// Anotaciones en variables, parámetros y valor de retorno
let horas: number = 12;
let titulo: string = 'Rediseñar la sala polivalente';
let revisor: string | null = null; // unión: una cosa u otra
function horasAbiertas(tareas: Tarea[]): number {
return tareas.filter((t) => t.estado !== 'hecha')
.reduce((suma, t) => suma + t.horasEstimadas, 0);
}Las interfaces describen la forma de un objeto. Son la documentación que el compilador comprueba:
// src/app/dominio/tarea.ts
export type Prioridad = 'alta' | 'media' | 'baja'; // tipo literal: solo esos tres valores
export type Estado = 'pendiente' | 'en-curso' | 'hecha';
export interface Tarea {
id: number;
titulo: string;
responsable: string | null; // R8: null, nunca ''
prioridad: Prioridad;
estado: Estado;
etiquetas: string[];
horasEstimadas: number;
fechaLimite: string; // ISO 'yyyy-mm-dd'
revisor: string | null;
}Fíjate en Prioridad. Es un tipo de unión de literales, y hace algo que ningún comentario consigue: si escribes prioridad: 'urgente', el compilador lo rechaza antes de ejecutar nada. Las reglas R1–R10 que llevas todo el curso comprobando a mano se convierten, en buena parte, en cosas que el compilador impide expresar.
Los genéricos son tipos con parámetros. Tarea[] es un array de tareas; Signal<number> es una señal que contiene un número; Observable<Tarea[]> es un flujo que emite arrays de tareas. Se leen como «X de Y».
Los modificadores de acceso son la versión de TypeScript de la encapsulación de 05-03:
class ServicioTablero {
private tareas: Tarea[] = []; // solo dentro de la clase (comprobado al compilar)
readonly hoy = '2026-09-20'; // no se puede reasignar
}Con un matiz que conviene saber: private de TypeScript desaparece al compilar y solo protege durante el desarrollo, mientras que los #campos de JavaScript de 05-03 son privados de verdad en tiempo de ejecución. Angular usa mayoritariamente private.
- Decoradores: qué son y por qué los usa Angular
Los decoradores son la sintaxis con @ que verás por todas partes:
@Component({ … })
export class TarjetaTareaComponent { }
@Injectable({ providedIn: 'root' })
export class ServicioTareas { }Un decorador es una función que recibe la clase y le añade metadatos. @Component({...}) no cambia el comportamiento de la clase: le adjunta información —cuál es su plantilla, qué selector usa, qué otros componentes importa— que Angular lee al compilar y al ejecutar.
La razón de que Angular los use es coherente con su filosofía: la configuración vive junto a lo que configura, de forma declarativa y legible. En React esa información está repartida entre el nombre del fichero, las importaciones y el JSX; en Angular está en un objeto explícito encima de la clase.
Para leer esta lección basta con entenderlos como «metadatos declarados encima de una clase». No necesitas escribir uno.
- La CLI y la estructura de un proyecto
La CLI de Angular hace mucho más que crear proyectos:
npm install -g @angular/cli
ng new nomada-angular # crea el proyecto y pregunta por SSR, estilos, etc.
cd nomada-angular
ng generate component componentes/tarjeta-tarea # o: ng g c componentes/tarjeta-tarea
ng generate service datos/tareas
ng generate guard seguridad/autenticado
ng serve # servidor de desarrollo
ng build # compilación de producción
ng test # pruebas unitarias
ng update # migraciones automáticas al actualizarng generate component crea cuatro ficheros y los registra donde corresponda:
src/app/componentes/tarjeta-tarea/ tarjeta-tarea.component.ts ← lógica tarjeta-tarea.component.html ← plantilla tarjeta-tarea.component.css ← estilos (con ámbito por defecto) tarjeta-tarea.component.spec.ts ← esqueleto de pruebas
Ese cuarto fichero dice mucho sobre las opiniones de Angular: la prueba se genera con el componente, no se añade después si a alguien le apetece. Es la misma idea que 08-03 defendía, convertida en comportamiento por defecto de la herramienta.
Y la estructura general:
src/
main.ts ← arranque
index.html
styles.css ← estilos globales
app/
app.config.ts ← configuración de la aplicación (proveedores)
app.routes.ts ← rutas
app.component.ts ← componente raíz
dominio/ ← tipos y reglas: TypeScript puro
datos/ ← servicios de datos
componentes/ ← componentesCompara con tu js/modelo/, js/datos/, js/vista/, js/util/. La separación es la misma que decidiste tú; la diferencia es que aquí viene dada y todo el mundo la usa igual.
- Componentes independientes (
standalone)
standalone)Durante años, Angular organizaba todo en NgModule: un componente no existía hasta declararlo en un módulo, y los módulos importaban otros módulos. Era una capa de indirección que costaba explicar y que a menudo sobraba.
Hoy la forma recomendada son los componentes independientes, que declaran directamente qué necesitan:
// src/app/componentes/filtro-responsable.component.ts
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-filtro-responsable',
template: `
<label class="filtro" for="filtro-responsable">
Responsable:
<select
id="filtro-responsable"
[value]="valor() ?? ''"
(change)="alCambiar($event)"
>
<option value="">Todos</option>
@for (nombre of responsables(); track nombre) {
<option [value]="nombre">{{ nombre }}</option>
}
</select>
</label>
`,
styles: `
.filtro { display: flex; gap: 0.5rem; align-items: center; }
`
})
export class FiltroResponsableComponent {
responsables = input.required<string[]>();
valor = input<string | null>(null);
cambiado = output<string | null>();
alCambiar(evento: Event): void {
const seleccion = (evento.target as HTMLSelectElement).value;
this.cambiado.emit(seleccion || null); // R8: null, nunca ''
}
}Los elementos del decorador:
selectores la etiqueta HTML con la que se usa:<app-filtro-responsable>. El prefijoapp-es la convención para evitar colisiones con elementos nativos.template(otemplateUrl) es la plantilla.styles(ostyleUrls) son los estilos, con ámbito por defecto, como elscopedde Vue de 10-04 pero sin tener que pedirlo.imports—que aquí no hace falta porque no se usa ningún componente ni directiva externa— lista lo que la plantilla necesita.
Los componentes independientes son hoy el valor por defecto de la CLI. Verás mucho código antiguo con NgModule; sigue funcionando y hay migraciones automáticas, pero para código nuevo no es la forma recomendada.
- Plantilla y estilos: las tres formas de escribirlos
| Forma | Cuándo | Cómo |
|---|---|---|
| Ficheros separados | Componentes grandes | templateUrl: './x.component.html', styleUrls: ['./x.component.css'] |
| En línea con acentos graves | Componentes pequeños | template: \…`` |
| Mezcla | Plantilla fuera, estilos dentro | Ambas cosas |
Esto es una diferencia real con Vue, y va en las dos direcciones. Angular por defecto usa tres ficheros para un componente (lógica, plantilla, estilos), lo que aleja las partes; pero el ámbito de estilos viene activado sin pedirlo y las herramientas del editor navegan entre los tres sin fricción. Para componentes pequeños, la forma en línea deja algo muy parecido a un fichero .vue.
El ámbito de estilos se implementa con atributos únicos añadidos por el compilador, igual que en Vue. Se puede cambiar a Shadow DOM real con encapsulation: ViewEncapsulation.ShadowDom, o desactivarlo — cosa que casi nunca conviene.
- Enlace de datos en sus cuatro formas
Angular tiene una sintaxis muy explícita para distinguir la dirección del dato, y una vez aprendida se lee muy bien:
<!-- 1 · Interpolación: valor → texto -->
<h3>{{ tarea.titulo }}</h3>
<p>{{ tarea.horasEstimadas }} h</p>
<!-- 2 · Propiedad: valor → atributo/propiedad del elemento -->
<button [disabled]="siguiente() === null">Empezar</button>
<li [attr.data-id]="tarea.id" [class.tarea--hecha]="tarea.estado === 'hecha'">
<!-- 3 · Evento: elemento → método del componente -->
<button (click)="avanzar(tarea.id)">Empezar</button>
<form (submit)="crear($event)">
<!-- 4 · Doble: propiedad + evento a la vez -->
<input [(ngModel)]="titulo">La mnemotecnia oficial es útil: los corchetes apuntan hacia dentro (el dato entra en el elemento), los paréntesis apuntan hacia fuera (el evento sale del elemento), y [()] —«caja de plátano en una caja»— hace las dos cosas.
Y [(ngModel)] es, como el v-model de Vue, puro azúcar:
<input [(ngModel)]="titulo">
<!-- equivale a -->
<input [ngModel]="titulo" (ngModelChange)="titulo = $event">Una propiedad que baja y un evento que sube. Otra vez el flujo unidireccional con un atajo encima. Aviso práctico: ngModel pertenece a FormsModule y hay que importarlo en el componente; y para formularios de cierta entidad, Angular recomienda los formularios reactivos del apartado 23, no ngModel.
Para clases y estilos hay formas específicas:
<li
class="tarea"
[class.tarea--hecha]="tarea.estado === 'hecha'"
[class.tarea--vencida]="vencida()"
[ngClass]="'tarea--' + tarea.prioridad"
[style.opacity]="tarea.estado === 'hecha' ? 0.6 : 1"
>
- La sintaxis de control de flujo actual
Durante años Angular usaba directivas estructurales con asterisco (*ngIf, *ngFor, ngSwitch). Hoy hay una sintaxis de bloques integrada en el compilador, que es la recomendada para código nuevo:
@if (cargando()) {
<p role="status">Cargando tareas…</p>
} @else if (error()) {
<p role="alert">No se han podido cargar: {{ error()!.message }}</p>
} @else {
<ul class="lista-tareas">
@for (tarea of visibles(); track tarea.id) {
<app-tarjeta-tarea [tarea]="tarea" (avanzar)="avanzar($event)" />
} @empty {
<p class="lista-vacia">Ninguna tarea coincide con el filtro.</p>
}
</ul>
}
@switch (tarea.estado) {
@case ('pendiente') { <span class="marca">○</span> }
@case ('en-curso') { <span class="marca">◐</span> }
@case ('hecha') { <span class="marca">●</span> }
}Cuatro ventajas sobre las directivas antiguas, todas prácticas:
- No hace falta importar nada.
*ngIfrequería importarCommonModuleoNgIf; olvidarlo producía un fallo silencioso en el que el contenido simplemente no aparecía. - Hay
@elsede verdad. Con*ngIfhabía que usar<ng-template>y referencias, que era considerablemente más incómodo. @emptycubre el estado vacío sin un@ifadicional. Es justo el caso que 06-06 te obligó a tratar aparte.- El compilador la entiende mejor y genera código más eficiente, además de mejores mensajes de error.
Compara las tres sintaxis para lo mismo:
| Operación | Angular | Vue | React |
|---|---|---|---|
| Condicional | @if (x) { … } @else { … } |
v-if / v-else |
{x ? … : …} |
| Lista | @for (t of ts; track t.id) { … } |
v-for + :key |
ts.map(t => <X key={t.id}/>) |
| Estado vacío | @empty { … } |
v-if="ts.length === 0" |
if (ts.length === 0) return … |
| Selección múltiple | @switch / @case |
v-if encadenados |
Objeto de búsqueda, o ternarios |
| Carga perezosa | @defer |
defineAsyncComponent |
lazy + Suspense |
@for y su track obligatorio
@for y su track obligatorioPor cuarta vez en el módulo, el mismo concepto. Y en Angular es donde está mejor resuelto, porque track no es opcional:
Si escribes @for sin track, el código no compila. No es un aviso en la consola como en React ni una regla de ESLint como en Vue: es un error de compilación que impide construir el proyecto.
Esa decisión resume la filosofía de Angular. La identidad estable en las listas es tan importante —lo sabes desde 06-06, cuando reconciliar sin data-id te habría costado el foco, las transiciones y el estado interno— que el framework no te deja olvidarla. Es «con opiniones» en su forma más útil: la opinión te obliga a hacer lo correcto.
Las mismas tres reglas, con la misma trampa: track $index está permitido y es lo correcto solo si la lista nunca se reordena ni se filtra. Con un id disponible, se usa el id.
Angular ofrece además variables contextuales dentro del bloque:
@for (tarea of visibles(); track tarea.id; let i = $index, primera = $first) {
<li [class.destacada]="primera">{{ i + 1 }}. {{ tarea.titulo }}</li>
}Disponibles: $index, $first, $last, $even, $odd, $count.
@defer: carga perezosa declarativa
@defer: carga perezosa declarativaEste bloque merece atención propia porque conecta directamente con 09-05 y porque no tiene equivalente tan integrado en los otros frameworks:
@defer (on viewport) {
<app-informe-carga [tareas]="tareas()" />
} @placeholder (minimum 500ms) {
<div class="esqueleto" style="min-height: 300px" aria-hidden="true"></div>
} @loading (after 100ms; minimum 300ms) {
<p role="status">Cargando el informe…</p>
} @error {
<p role="alert">No se ha podido cargar el informe.</p>
}Lo que hace: el componente app-informe-carga y todas sus dependencias se separan en un trozo aparte durante la compilación, y solo se descargan cuando se cumple la condición. Los disparadores disponibles son on viewport, on interaction, on hover, on idle, on timer(…), on immediate, y when <expresión>, además de prefetch on … para precargar sin renderizar.
Compara con lo que escribiste en 09-05:
// Tu versión, a mano
let promesaInforme = null;
boton.addEventListener('click', async () => {
indicador.hidden = false;
indicador.setAttribute('aria-busy', 'true');
try {
promesaInforme ??= import('./informe/informe.js');
const { montarInforme } = await promesaInforme;
montarInforme(contenedor, tablero);
} catch (error) {
promesaInforme = null; // permitir reintento
mostrarFallo(contenedor);
} finally {
indicador.hidden = true;
}
});Y con el IntersectionObserver con rootMargin que también escribiste. @defer hace todo eso —la división del código, el disparador, el indicador con su retardo mínimo para evitar parpadeos, el hueco reservado que evita el CLS, y el manejo del fallo— de forma declarativa y comprobada por el compilador. Es exactamente el argumento de 10-01: no es que no supieras hacerlo; es que aquí lo escribes en ocho líneas de plantilla y no puedes olvidarte del estado de error.
- Señales:
signal, computed y effect
signal, computed y effectLas señales son el modelo de reactividad actual de Angular, y su parecido con los ref de Vue no es casual: son la misma familia 2 de 10-01.
import { signal, computed, effect } from '@angular/core';
// Una señal escribible
const responsable = signal<string | null>(null);
// Leer: se llama como función
console.log(responsable()); // null
// Escribir: dos formas
responsable.set('Iván'); // valor nuevo directo
responsable.update((actual) => actual ?? 'Iván'); // en función del actualLa diferencia sintáctica con Vue: donde Vue usa .value (una propiedad), Angular usa () (una llamada). El motivo es el mismo del apartado 12 de 10-04 —JavaScript no permite interceptar la lectura de una variable—, resuelto con otra herramienta: una función en lugar de un getter.
computed declara un derivado, con caché y dependencias automáticas:
const tareas = signal<Tarea[]>(BACKLOG);
const visibles = computed(() => {
const r = responsable();
return r === null ? tareas() : tareas().filter((t) => t.responsable === r);
});
const horasAbiertas = computed(() =>
visibles().filter((t) => t.estado !== 'hecha')
.reduce((s, t) => s + t.horasEstimadas, 0)
);Idéntico en semántica al computed de Vue: perezoso, cacheado, componible, con las dependencias descubiertas al leer. Y por tanto idéntico también a tu caché por versión de 09-02, con la invalidación automática.
effect ejecuta efectos secundarios cuando cambian sus dependencias:
effect(() => {
document.title = `Nómada Tareas (${pendientes()})`;
});
// Con limpieza, que es otra vez tu destruir()
effect((alLimpiar) => {
const canal = new CanalTablero();
canal.suscribir(alRecibir);
alLimpiar(() => canal.cerrar());
});Con la misma advertencia de useEffect y de watch: effect no es para derivar valores. Escribir en una señal desde dentro de un effect para calcular algo es el mismo antipatrón que has visto ya dos veces, y Angular lo desaconseja explícitamente (hasta el punto de que escribir señales dentro de un efecto requiere una opción especial).
Hay un detalle relevante sobre inmutabilidad. Una señal detecta el cambio comparando con Object.is, como React y no como Vue: no hay proxy que intercepte la escritura en una propiedad anidada.
// ❌ No detecta nada: la referencia del array no cambia
tareas()[5].estado = 'hecha';
// ✅ Referencia nueva
tareas.update((ts) => ts.map((t) => (t.id === 6 ? { ...t, estado: 'hecha' } : t)));Es decir: en Angular con señales, la inmutabilidad de 04-07 vuelve a ser obligatoria, igual que en React. Es un buen ejemplo de que las tres familias de 10-01 se cruzan: Angular tiene reactividad de grano fino con requisito de inmutabilidad; Vue tiene grano fino con mutación permitida; React tiene DOM virtual con inmutabilidad obligatoria.
- Entradas y salidas basadas en señales
La comunicación entre componentes usa input() y output(), que sustituyen a los decoradores @Input() y @Output() del Angular anterior:
import { Component, input, output, computed } from '@angular/core';
import { Tarea, Estado } from '../dominio/tarea';
import { SIGUIENTE, ETIQUETA, estaVencida } from '../dominio/reglas';
@Component({
selector: 'app-tarjeta-tarea',
template: `
<li
class="tarea"
[class]="'tarea--' + tarea().prioridad"
[class.tarea--hecha]="tarea().estado === 'hecha'"
[class.tarea--vencida]="vencida()"
[attr.data-id]="tarea().id"
>
<h3 class="tarea__titulo">{{ tarea().titulo }}</h3>
<p class="tarea__meta">
{{ tarea().responsable ?? 'sin asignar' }} · {{ tarea().horasEstimadas }} h
@if (vencida()) { <span class="tarea__aviso"> · ⚠ vencida</span> }
</p>
<ul class="tarea__etiquetas">
@for (etiqueta of tarea().etiquetas; track etiqueta) {
<li class="etiqueta">{{ etiqueta }}</li>
}
</ul>
<button
type="button"
[disabled]="siguiente() === null"
[attr.aria-label]="ETIQUETA[tarea().estado] + ': ' + tarea().titulo"
(click)="avanzar.emit(tarea().id)"
>
{{ ETIQUETA[tarea().estado] }}
</button>
</li>
`,
styles: `
.tarea { border-left: 4px solid var(--borde); padding: 0.75rem; }
.tarea--alta { border-left-color: var(--rojo); }
.tarea--hecha .tarea__titulo { text-decoration: line-through; opacity: 0.6; }
.tarea__etiquetas { list-style: none; display: flex; gap: 0.3rem; padding: 0; }
`
})
export class TarjetaTareaComponent {
tarea = input.required<Tarea>();
avanzar = output<number>();
protected readonly ETIQUETA = ETIQUETA;
vencida = computed(() => estaVencida(this.tarea()));
siguiente = computed<Estado | null>(() => SIGUIENTE[this.tarea().estado]);
}Puntos importantes:
input.required<Tarea>()declara una entrada obligatoria y tipada. Si el padre no la pasa, falla al compilar. Compáralo con las props de React, donde solo TypeScript lo detecta, o condefinePropsde Vue, que valida en tiempo de ejecución en desarrollo.- Una entrada es una señal. Se lee con
tarea()y se puede usar directamente como dependencia de uncomputed. Eso significa quevencidase recalcula sola cuando el padre pasa otra tarea, sin nada parecido al array de dependencias. output<number>()declara un evento tipado. El padre lo escucha con(avanzar)="…".ETIQUETAse expone como propiedad porque las plantillas de Angular solo ven miembros de la clase, no las importaciones del módulo. Es una diferencia real con JSX (donde todo el ámbito del fichero está disponible) y con Vue (donde<script setup>expone todo automáticamente): en Angular hay que hacerlo explícito.
- Zone.js, la detección de cambios y la tendencia zoneless
Para entender el Angular que verás en proyectos reales hace falta saber de dónde viene su reactividad.
Durante casi toda su historia, Angular no supo qué había cambiado: lo comprobaba todo. El mecanismo era Zone.js, una librería que parchea las APIs asíncronas del navegador —setTimeout, addEventListener, fetch, Promise— para avisar a Angular cada vez que ocurre algo. Al recibir el aviso, Angular recorre el árbol de componentes evaluando todas las expresiones de todas las plantillas y comparando los resultados con los anteriores.
graph TD E["Cualquier evento asíncrono<br/>(clic, timeout, respuesta HTTP)"] --> Z["Zone.js lo detecta"] Z --> D["Angular ejecuta la detección de cambios"] D --> T["Recorre TODOS los componentes<br/>y evalúa TODAS las expresiones"] T --> C["Actualiza el DOM donde algo difiera"]
Funciona, y es la razón de que Angular «simplemente actualice» cuando mutas una propiedad. Pero tiene tres costes: comprueba mucho más de lo necesario; Zone.js pesa y parchea APIs globales, lo cual complica la depuración y la interoperabilidad; y no da información de grano fino, así que hay que optimizar a mano con ChangeDetectionStrategy.OnPush.
Las señales cambian eso. Como una señal sabe exactamente qué expresiones la leyeron, Angular puede actualizar solo lo que depende de lo que cambió. Ese es el fundamento de la tendencia zoneless: aplicaciones donde toda la reactividad pasa por señales y Zone.js deja de hacer falta, con lo que desaparece del paquete y la detección de cambios pasa a ser dirigida por datos en lugar de por sondeo.
En la práctica, para escribir Angular moderno la regla es: usa señales para todo el estado. Si lo haces, el modelo mental es el mismo que el de Vue y no necesitas pensar en Zone.js. Pero al leer código existente te encontrarás propiedades normales de clase actualizándose solas: eso es Zone.js trabajando, y ahora sabes por qué.
- Señales de Angular frente a
ref de Vue
ref de Vueref de Vue |
signal de Angular |
|
|---|---|---|
| Leer | x.value (plantilla: x) |
x() (plantilla: x()) |
| Escribir | x.value = 5 |
x.set(5) o x.update(f) |
| Derivar | computed(() => …) |
computed(() => …) |
| Efecto | watchEffect, watch |
effect |
| Detección de cambio | Proxy: intercepta escrituras en propiedades | Object.is sobre el valor |
| Objetos anidados | Reactivos en profundidad | No: hay que reemplazar la referencia |
| Inmutabilidad | No hace falta | Obligatoria |
| Se pierde al desestructurar | Sí (toRefs lo evita) |
No: una señal es una función, se pasa entera |
| Fuera de componentes | Sí | Sí |
Dos filas merecen comentario.
La de la inmutabilidad es la diferencia práctica más visible, y va acompañada de una compensación: como no hay proxy, las señales de Angular son más baratas y más previsibles; no hay que preguntarse si un objeto está envuelto ni si un Map es reactivo. A cambio, escribes el map inmutable de 04-07 en cada actualización.
La de desestructurar es una ventaja clara de las señales: como el valor se obtiene llamando a una función, pasar la señal a otro sitio nunca rompe nada. Todo el apartado 15 de 10-04 —los cuatro límites de la reactividad de Vue— simplemente no aplica aquí. Es un caso donde la sintaxis más incómoda (() en cada lectura) compra una propiedad valiosa.
- Inyección de dependencias: servicios e
inject()
inject()Aquí está lo que de verdad diferencia a Angular. Los otros dos frameworks no tienen nada equivalente.
Un servicio es una clase con lógica que no pertenece a ningún componente: acceso a datos, estado compartido, reglas de negocio, registro de eventos.
// src/app/datos/tareas.service.ts
import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Tarea } from '../dominio/tarea';
import { SIGUIENTE } from '../dominio/reglas';
@Injectable({ providedIn: 'root' }) // ← una única instancia para toda la aplicación
export class TareasService {
private readonly http = inject(HttpClient); // ← inyección por función
private readonly _tareas = signal<Tarea[]>([]);
readonly tareas = this._tareas.asReadonly(); // expuesta como solo lectura
readonly responsables = computed(() =>
[...new Set(this.tareas().map((t) => t.responsable).filter(Boolean))].sort()
);
readonly horasAbiertas = computed(() =>
this.tareas().filter((t) => t.estado !== 'hecha')
.reduce((s, t) => s + t.horasEstimadas, 0)
);
cargar(): void {
this.http.get<Tarea[]>('/api/tareas')
.subscribe((tareas) => this._tareas.set(tareas));
}
avanzar(id: number): void {
this._tareas.update((tareas) =>
tareas.map((t) => {
if (t.id !== id) return t;
const destino = SIGUIENTE[t.estado];
return destino === null ? t : { ...t, estado: destino }; // R6
})
);
}
}Y su uso desde un componente:
@Component({ … })
export class TableroComponent {
private readonly servicio = inject(TareasService);
tareas = this.servicio.tareas;
horasAbiertas = this.servicio.horasAbiertas;
}Tres cosas que hay que entender de este código:
@Injectable({ providedIn: 'root' }) registra el servicio en el inyector raíz. Angular crea una sola instancia y se la da a todo el que la pida. Es un singleton, pero gestionado por el framework en lugar de por un módulo exportando un objeto.
inject(TareasService) pide la instancia. No hay new, no hay importación de una instancia concreta: se pide el tipo y el inyector decide qué dar. Esa indirección es la clave de todo lo que viene después.
El estado está en el servicio, no en el componente. El patrón _tareas privado escribible más tareas público de solo lectura (asReadonly()) es la encapsulación de 05-03 aplicada al estado: el servicio es el único que puede cambiarlo, y los componentes solo leen. Es exactamente la disciplina que tu clase Tablero imponía con #tareas y el getter que devolvía una copia.
- Por qué la inyección de dependencias diferencia a Angular
La pregunta razonable es: ¿qué aporta pedir inject(TareasService) en lugar de importar una instancia?
Aporta que quien decide qué instancia se entrega no es quien la usa. Y de ahí salen cuatro capacidades:
1 · Sustituir en pruebas. Al probar el componente, se le da un servicio falso sin tocar el componente:
TestBed.configureTestingModule({
providers: [{ provide: TareasService, useClass: TareasServiceFalso }]
});2 · Cambiar la implementación por entorno. El mismo componente usa un servicio contra la API real en producción y contra datos locales en desarrollo, decidido en la configuración.
3 · Ámbitos distintos. Un servicio puede ser único para toda la aplicación (providedIn: 'root'), o crearse una instancia por componente si se declara en sus providers. Eso permite, por ejemplo, un servicio de estado por cada panel abierto.
4 · Interceptores. HttpClient se puede envolver con interceptores que añaden cabeceras de autenticación, reintentan (tu conReintentos de 07-03), registran o gestionan errores, sin que ningún servicio ni componente se entere.
El coste, dicho claro: es un concepto más que aprender, y uno que no existe en React ni en Vue. Para una aplicación pequeña es ceremonia; para una grande con muchas capas y pruebas exhaustivas, es la pieza que mantiene el código desacoplado.
- El parecido con lo que hiciste en 08-04
Y aquí está la conexión que hace que todo esto te resulte familiar. En 08-04 escribiste esto para poder probar tu repositorio:
// js/datos/repositorio-local.js — 08-04
export class RepositorioLocal {
#almacen;
constructor(almacen = window.localStorage) { // ← inyección por parámetro
this.#almacen = almacen;
}
guardar(tablero) {
this.#almacen.setItem('nomada:tablero', JSON.stringify(tablero));
}
}// test/repositorio-local.test.js
const almacenFalso = new AlmacenEnMemoria();
const repo = new RepositorioLocal(almacenFalso); // ← se inyecta el dobleEso es inyección de dependencias. No la inventó Angular: es un patrón de diseño con décadas, y tú lo aplicaste por la razón correcta —para poder probar sin depender de localStorage—. Lo que hace Angular es dos cosas más:
- Convertirla en la norma, no en algo que se hace cuando alguien se acuerda.
- Automatizar el cableado: en tu versión, quien construye
RepositorioLocaltiene que saber qué almacén pasarle, y ese conocimiento se propaga hacia arriba. Con un inyector, se declara una vez qué implementación corresponde a cada tipo y el framework lo resuelve en todo el árbol.
Si el patrón te pareció útil en 08-04 —y lo era, porque sin él aquellas pruebas habrían necesitado un navegador real—, Angular te lo da en toda la aplicación sin esfuerzo adicional.
HttpClient y RxJS: lo justo
HttpClient y RxJS: lo justoHttpClient es el cliente HTTP incluido, y devuelve observables en lugar de promesas:
import { inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { catchError, retry, map, of } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class ApiTareasService {
private readonly http = inject(HttpClient);
listarTareas(responsable: string | null) {
let params = new HttpParams();
if (responsable) params = params.set('responsable', responsable);
return this.http.get<Tarea[]>('/api/tareas', { params }).pipe(
retry({ count: 3, delay: 500 }), // tu conReintentos de 07-03
map((tareas) => tareas.filter((t) => t.titulo.trim() !== '')), // R2
catchError((error) => {
console.error('Fallo al listar tareas', error);
return of([]); // degradación digna
})
);
}
}Lo mínimo de RxJS para leer esto, sin más:
Un observable es un flujo de valores en el tiempo. Una promesa produce un valor (o un fallo) y termina; un observable puede producir cero, uno o muchos, y puede no terminar nunca. Un clic de ratón es un flujo. Un WebSocket —tu CanalTablero de 07-04— es un flujo. Una petición HTTP es un flujo que emite un valor y termina, y por eso parece una promesa con sintaxis rara.
subscribe arranca el flujo. Un observable es perezoso: hasta que alguien se suscribe, no ocurre nada. Esta es la trampa número uno para quien viene de promesas: llamar a listarTareas() sin subscribe no hace ninguna petición. Una promesa, en cambio, empieza a ejecutarse en cuanto se crea.
this.api.listarTareas(null).subscribe({
next: (tareas) => this.tareas.set(tareas),
error: (fallo) => this.error.set(fallo),
complete: () => this.cargando.set(false)
});pipe encadena operadores. Un operador transforma el flujo y devuelve otro flujo. Los que aparecen constantemente:
| Operador | Qué hace | Tu equivalente |
|---|---|---|
map |
Transforma cada valor | Array.prototype.map de 04-04 |
filter |
Descarta valores | Array.prototype.filter |
retry |
Reintenta al fallar | Tu conReintentos de 07-03 |
catchError |
Captura el error y devuelve otro flujo | El catch de 02-05 |
debounceTime |
Espera a que pare de emitir | Tu debounce de 09-02 |
switchMap |
Cambia a otro flujo y cancela el anterior | Tu AbortController de 07-03 |
takeUntilDestroyed |
Se da de baja al destruir el componente | Tu destruir() de 09-03 |
switchMap merece un ejemplo, porque resuelve elegantemente la condición de carrera de 10-02:
// El filtro es un flujo; cada cambio cancela la petición anterior
readonly tareas = toSignal(
toObservable(this.responsable).pipe(
debounceTime(300), // no consultar en cada tecla
switchMap((r) => this.api.listarTareas(r)) // cancela la petición anterior
),
{ initialValue: [] as Tarea[] }
);Ese switchMap es la respuesta canónica al problema que en React resolviste abortando a mano y en Vue con alLimpiar. Cuando llega un valor nuevo, se cancela la suscripción anterior —y con ella la petición HTTP— y se suscribe a la nueva. La carrera es imposible por construcción.
Y toSignal/toObservable son los puentes entre los dos mundos: convierten un observable en señal y viceversa. Son la herramienta que permite escribir la aplicación con señales y usar RxJS solo donde aporta.
- El
async de la plantilla y por qué hay que darse de baja
async de la plantilla y por qué hay que darse de bajaEn la plantilla, el pipe async se suscribe y se da de baja automáticamente:
@if (tareas$ | async; as tareas) {
<ul>
@for (t of tareas; track t.id) { <li>{{ t.titulo }}</li> }
</ul>
}Es la forma recomendada cuando se trabaja con observables en plantillas, precisamente porque elimina el problema de darse de baja.
Y ese problema es real. Un observable que no termina —un WebSocket, un flujo de eventos, un temporizador— mantiene viva su suscripción aunque el componente desaparezca. Es literalmente la fuga de memoria de 09-03: el manejador sigue en memoria, sigue procesando, y mantiene vivo el componente y todo su subárbol.
// ❌ Fuga: la suscripción sobrevive al componente
export class TableroComponent implements OnInit {
ngOnInit() {
this.canal.mensajes$.subscribe((m) => this.aplicar(m));
}
}
// ✅ Baja automática al destruir el componente
export class TableroComponent {
private readonly destruirRef = inject(DestroyRef);
constructor() {
this.canal.mensajes$
.pipe(takeUntilDestroyed(this.destruirRef))
.subscribe((m) => this.aplicar(m));
}
}takeUntilDestroyed es tu destruir(), una vez más, con la llamada garantizada por el framework. Las tres formas correctas de gestionar la baja, por orden de preferencia: el pipe async en la plantilla, takeUntilDestroyed, y —si no hay más remedio— guardar la suscripción y llamar a unsubscribe() en ngOnDestroy.
- Cuándo señales y cuándo observables
Angular tiene ahora dos sistemas reactivos conviviendo, lo cual desconcierta. El criterio actual es razonablemente claro:
| Usa… | Para… | Ejemplos |
|---|---|---|
| Señales | Estado síncrono que la interfaz muestra | Filtro actual, lista de tareas, si un panel está abierto |
| Observables | Flujos asíncronos con composición compleja | HTTP, WebSocket, eventos del DOM con retardo, secuencias con cancelación |
| Los puentes | Pasar de un mundo al otro | toSignal para pintar un flujo; toObservable para componer sobre una señal |
En la práctica, el Angular moderno tiende a: señales para casi todo el estado de la aplicación, RxJS para la capa de datos y las interacciones complejas, y toSignal en la frontera. Es un modelo con más piezas que Vue o React, y esa es una crítica legítima a la curva de aprendizaje de Angular — con la contrapartida de que RxJS resuelve problemas de coordinación asíncrona que en los otros frameworks se resuelven a mano o con librerías.
- El enrutador con carga perezosa
// src/app/app.routes.ts
import { Routes } from '@angular/router';
export const routes: Routes = [
{ path: '', redirectTo: 'tablero', pathMatch: 'full' },
{
path: 'tablero',
// Carga perezosa: este componente va en su propio trozo
loadComponent: () => import('./componentes/tablero.component')
.then((m) => m.TableroComponent),
title: 'Tablero · Nómada Tareas'
},
{
path: 'tarea/:id',
loadComponent: () => import('./componentes/detalle-tarea.component')
.then((m) => m.DetalleTareaComponent)
},
{
path: 'informe',
loadComponent: () => import('./componentes/informe.component')
.then((m) => m.InformeComponent),
canActivate: [autenticadoGuard] // guarda de ruta
},
{ path: '**', loadComponent: () => import('./componentes/no-encontrado.component')
.then((m) => m.NoEncontradoComponent) }
];Ese loadComponent con import() dinámico es exactamente la división de código de 09-05, integrada en el enrutador: cada ruta se compila en su propio trozo y solo se descarga al navegar a ella. Es la fila «Angular trae división de código en el enrutador» que 09-05 anunciaba.
Y hay una pieza que en tu enrutador.js no tenías: las guardas. canActivate ejecuta una función antes de activar la ruta y puede impedir la navegación o redirigir:
export const autenticadoGuard: CanActivateFn = () => {
const sesion = inject(SesionService);
const router = inject(Router);
return sesion.autenticado() ? true : router.createUrlTree(['/entrar']);
};En la plantilla, la navegación se hace con directivas:
<nav>
<a routerLink="/tablero" routerLinkActive="activo">Tablero</a>
<a [routerLink]="['/tarea', tarea.id]">{{ tarea.titulo }}</a>
</nav>
<router-outlet />routerLink genera un <a href> real —importante para la accesibilidad y el SEO— e intercepta el clic para navegar sin recargar, que es exactamente lo que hacía tu enrutador con la History API de 07-06.
- Formularios reactivos frente a formularios de plantilla
Angular incluye dos sistemas, y esta es la comparación que decide cuál usar:
De plantilla (ngModel) |
Reactivos (FormGroup) |
|
|---|---|---|
| Dónde se define la estructura | En el HTML | En TypeScript |
| Tipado | Débil | Fuerte, comprobado al compilar |
| Validación | Atributos en la plantilla | Funciones validadoras |
| Validación asíncrona | Incómoda | Integrada |
| Campos dinámicos | Difícil | FormArray |
| Pruebas | Requiere renderizar | Se prueba el modelo directamente |
| Formularios sencillos | Rápido de escribir | Más ceremonia |
| Recomendación de Angular | Casos simples | Todo lo demás |
Los reactivos en acción:
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-formulario-tarea',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="formulario" (ngSubmit)="enviar()" novalidate>
<label for="titulo">Título</label>
<input id="titulo" formControlName="titulo"
[attr.aria-invalid]="titulo.invalid && titulo.touched">
@if (titulo.touched && titulo.hasError('required')) {
<p class="error" role="alert">El título es obligatorio (R2).</p>
}
@if (titulo.touched && titulo.hasError('soloEspacios')) {
<p class="error" role="alert">El título no puede ser solo espacios (R2).</p>
}
<label for="horas">Horas estimadas</label>
<input id="horas" type="number" formControlName="horasEstimadas">
@if (horas.touched && horas.invalid) {
<p class="error" role="alert">Entre 1 y 40 horas (R3).</p>
}
<button type="submit" [disabled]="formulario.invalid">Crear tarea</button>
</form>
`
})
export class FormularioTareaComponent {
private readonly fb = inject(FormBuilder);
formulario = this.fb.nonNullable.group({
titulo: ['', [Validators.required, sinSoloEspacios]],
horasEstimadas: [1, [Validators.required, Validators.min(1), Validators.max(40)]],
fechaLimite: ['', [Validators.required, noAnteriorAHoy]],
responsable: [null as string | null]
});
get titulo() { return this.formulario.controls.titulo; }
get horas() { return this.formulario.controls.horasEstimadas; }
enviar(): void {
if (this.formulario.invalid) {
this.formulario.markAllAsTouched(); // muestra todos los errores de una vez
return;
}
const datos = this.formulario.getRawValue(); // tipado: {titulo: string, …}
// …crear la tarea
}
}
- Validadores con las reglas R1–R10
Un validador es una función pura que recibe el control y devuelve null si es válido o un objeto de errores si no. Es decir: exactamente la forma de tus funciones de validación de 03-03, con otra firma.
// src/app/dominio/validadores.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
import { HOY } from './reglas';
/** R2: el título no puede estar vacío ni contener solo espacios. */
export function sinSoloEspacios(control: AbstractControl): ValidationErrors | null {
const valor = control.value as string;
return typeof valor === 'string' && valor.trim() === '' ? { soloEspacios: true } : null;
}
/** R4: la fecha límite no puede ser anterior a la de creación. */
export function noAnteriorAHoy(control: AbstractControl): ValidationErrors | null {
const valor = control.value as string;
if (!valor) return null;
return valor < HOY ? { fechaPasada: { minimo: HOY, actual: valor } } : null;
}
/** R9: las etiquetas se guardan en minúsculas y sin duplicados. */
export function etiquetasNormalizadas(control: AbstractControl): ValidationErrors | null {
const valor = (control.value ?? []) as string[];
const normalizadas = valor.map((e) => e.trim().toLowerCase());
const hayDuplicados = new Set(normalizadas).size !== normalizadas.length;
const hayMayusculas = valor.some((e) => e !== e.toLowerCase());
if (hayDuplicados) return { etiquetasDuplicadas: true };
if (hayMayusculas) return { etiquetasEnMayusculas: true };
return null;
}
/** R7: nadie puede superar 40 h asignadas en la misma semana. Validador de grupo. */
export function limiteSemanal(cargaActual: Map<string, number>): ValidatorFn {
return (grupo: AbstractControl): ValidationErrors | null => {
const responsable = grupo.get('responsable')?.value as string | null;
const horas = Number(grupo.get('horasEstimadas')?.value ?? 0);
if (!responsable) return null; // R8: sin responsable, no aplica
const total = (cargaActual.get(responsable) ?? 0) + horas;
return total > 40 ? { limiteSemanal: { responsable, total, maximo: 40 } } : null;
};
}Tres observaciones:
- Son funciones puras y se prueban con Jest sin Angular.
sinSoloEspacios({value: ' '} as AbstractControl)devuelve{soloEspacios: true}. Es la misma facilidad de prueba que tenían tus validadores de 03-03 y tus reductores de 10-03. limiteSemanales una fábrica de validadores —una función que devuelve una función, de 03-06— porque necesita datos externos. Y es un validador de grupo, porque la R7 depende de dos campos a la vez.- El objeto de error lleva datos, no solo
true. Eso permite mensajes concretos: «Iván llegaría a 45 h de las 40 permitidas» en lugar de «error». Es la misma idea que tuErrorDeValidacioncon.campodejs/modelo/errores.js.
- Pruebas con TestBed
Angular trae su propio entorno, que monta un mini-inyector para la prueba:
import { TestBed } from '@angular/core/testing';
import { TableroComponent } from './tablero.component';
import { TareasService } from '../datos/tareas.service';
import { signal } from '@angular/core';
import { BACKLOG } from '../datos/backlog';
describe('TableroComponent', () => {
let servicioFalso: Partial<TareasService>;
beforeEach(async () => {
servicioFalso = {
tareas: signal(BACKLOG).asReadonly(),
avanzar: jasmine.createSpy('avanzar')
};
await TestBed.configureTestingModule({
imports: [TableroComponent],
providers: [{ provide: TareasService, useValue: servicioFalso }] // ← el doble
}).compileComponents();
});
it('muestra las 6 tareas del backlog canónico', () => {
const fixture = TestBed.createComponent(TableroComponent);
fixture.detectChanges();
const filas = fixture.nativeElement.querySelectorAll('[data-id]');
expect(filas.length).toBe(6);
});
it('al filtrar por Lucía queda una tarea de 14 h', () => {
const fixture = TestBed.createComponent(TableroComponent);
fixture.componentInstance.responsable.set('Lucía');
fixture.detectChanges();
const filas = fixture.nativeElement.querySelectorAll('[data-id]');
expect(filas.length).toBe(1);
expect(fixture.nativeElement.textContent).toContain('Actualizar la web de reservas');
});
});La línea que importa es providers: [{ provide: TareasService, useValue: servicioFalso }]. Eso es lo que compra la inyección de dependencias. El componente pide TareasService; en producción recibe el real, en la prueba recibe el doble, y el componente no cambia ni se entera. Es exactamente lo que hiciste a mano en 08-04 pasando AlmacenEnMemoria al constructor de RepositorioLocal, con el cableado automatizado.
Angular incluye Karma y Jasmine por defecto, pero se puede usar Jest o Vitest, y Testing Library tiene versión para Angular, con las mismas consultas por rol que ya usas desde 08-05. La filosofía de probar lo que ve la usuaria no cambia de framework.
- Una nota sobre SSR
Angular incluye renderizado en servidor sin librerías externas:
Eso añade un servidor que renderiza el HTML antes de enviarlo, con hidratación en el cliente para que la página siga siendo interactiva, y soporte para hidratación incremental combinada con @defer, que permite decidir qué partes se hidratan y cuándo.
No lo desarrollamos aquí: el renderizado en cliente, servidor, estático e híbrido es el tema de la próxima lección, donde verás por qué esa decisión suele importar más que la del framework.
- Nómada Tareas en Angular: la lista completa
Cuarta y última versión de la misma pantalla. El dominio, en TypeScript pero conceptualmente idéntico:
// src/app/dominio/reglas.ts
import { Tarea, Estado } from './tarea';
export const SIGUIENTE: Record<Estado, Estado | null> = {
pendiente: 'en-curso',
'en-curso': 'hecha',
hecha: null
};
export const ETIQUETA: Record<Estado, string> = {
pendiente: 'Empezar',
'en-curso': 'Marcar hecha',
hecha: 'Hecha'
};
export const PESOS = { alta: 3, media: 2, baja: 1 } as const;
export const HOY = '2026-09-20';
export const estaVencida = (t: Tarea, hoy = HOY): boolean =>
t.estado !== 'hecha' && t.fechaLimite < hoy;El servicio, que es donde vive el estado:
// src/app/datos/tablero.service.ts
import { Injectable, signal, computed } from '@angular/core';
import { Tarea } from '../dominio/tarea';
import { SIGUIENTE } from '../dominio/reglas';
import { BACKLOG } from './backlog';
@Injectable({ providedIn: 'root' })
export class TableroService {
private readonly _tareas = signal<Tarea[]>(BACKLOG);
readonly tareas = this._tareas.asReadonly();
readonly responsables = computed(() =>
[...new Set(this.tareas().map((t) => t.responsable).filter((r): r is string => r !== null))]
.sort()
);
readonly resumen = computed(() => {
const todas = this.tareas();
const abiertas = todas.filter((t) => t.estado !== 'hecha');
return {
total: todas.length,
horasTotales: todas.reduce((s, t) => s + t.horasEstimadas, 0),
horasAbiertas: abiertas.reduce((s, t) => s + t.horasEstimadas, 0)
};
});
/** Avanza el estado de una tarea respetando la R6. Inmutable: la señal compara por referencia. */
avanzar(id: number): void {
this._tareas.update((tareas) =>
tareas.map((t) => {
if (t.id !== id) return t;
const destino = SIGUIENTE[t.estado];
return destino === null ? t : { ...t, estado: destino };
})
);
}
}La tarjeta es la del apartado 13. La lista:
// src/app/componentes/lista-tareas.component.ts
import { Component, input, output } from '@angular/core';
import { Tarea } from '../dominio/tarea';
import { TarjetaTareaComponent } from './tarjeta-tarea.component';
@Component({
selector: 'app-lista-tareas',
imports: [TarjetaTareaComponent], // ← el componente independiente declara lo que usa
template: `
<ul class="lista-tareas">
@for (tarea of tareas(); track tarea.id) {
<app-tarjeta-tarea [tarea]="tarea" (avanzar)="avanzar.emit($event)" />
} @empty {
<p class="lista-vacia">Ninguna tarea coincide con el filtro.</p>
}
</ul>
`,
styles: `
.lista-tareas { list-style: none; padding: 0; display: grid; gap: 0.5rem; }
.lista-vacia { color: var(--gris); font-style: italic; }
`
})
export class ListaTareasComponent {
tareas = input.required<Tarea[]>();
avanzar = output<number>();
}Y el componente raíz:
// src/app/app.component.ts
import { Component, inject, signal, computed } from '@angular/core';
import { TableroService } from './datos/tablero.service';
import { FiltroResponsableComponent } from './componentes/filtro-responsable.component';
import { ListaTareasComponent } from './componentes/lista-tareas.component';
@Component({
selector: 'app-root',
imports: [FiltroResponsableComponent, ListaTareasComponent],
template: `
<main class="tablero">
<header class="tablero__cabecera">
<h1>Nómada Tareas</h1>
<app-filtro-responsable
[responsables]="servicio.responsables()"
[valor]="responsable()"
(cambiado)="responsable.set($event)"
/>
<p class="tablero__resumen">
{{ visibles().length }} de {{ servicio.resumen().total }} tareas ·
{{ horasVisibles() }} h abiertas
</p>
</header>
<app-lista-tareas [tareas]="visibles()" (avanzar)="servicio.avanzar($event)" />
</main>
`,
styles: `
.tablero__cabecera { display: flex; gap: 1rem; align-items: baseline; flex-wrap: wrap; }
.tablero__resumen { color: var(--gris); margin-left: auto; }
`
})
export class AppComponent {
protected readonly servicio = inject(TableroService);
readonly responsable = signal<string | null>(null);
readonly visibles = computed(() => {
const r = this.responsable();
return r === null ? this.servicio.tareas()
: this.servicio.tareas().filter((t) => t.responsable === r);
});
readonly horasVisibles = computed(() =>
this.visibles().filter((t) => t.estado !== 'hecha')
.reduce((s, t) => s + t.horasEstimadas, 0)
);
}Los números canónicos, comprobados: sin filtro, «6 de 6 tareas · 45 h abiertas». Con «Iván», «3 de 6 · 25 h». Con «Lucía», «1 de 6 · 14 h». Con «Marta», «2 de 6 · 6 h».
- Comparación con las tres versiones anteriores
| Concepto | JavaScript puro | React | Vue | Angular |
|---|---|---|---|---|
| Lenguaje | JavaScript | JS o TS | JS o TS | TypeScript |
| Unidad | Módulo + <template> + CSS |
Función .jsx |
Fichero .vue |
Clase con decorador |
| Estilos aislados | No | No por defecto | Sí (scoped) |
Sí, por defecto |
| Estado | Objeto + render() |
useState |
ref |
signal |
| Derivados | Caché por versión | useMemo |
computed |
computed |
| Inmutabilidad | Recomendada | Obligatoria | No hace falta | Obligatoria |
| Identidad en listas | data-id + reconciliar |
key (aviso si falta) |
:key (error de ESLint) |
track (error de compilación) |
| Limpieza | destruir() a mano |
return del efecto |
onUnmounted |
DestroyRef, takeUntilDestroyed |
| Datos remotos | pedirJson propio |
Se elige | Se elige | HttpClient incluido |
| Estado compartido | Objeto de módulo | Se elige (10-03) | Pinia | Servicio inyectado |
| Inyección de dependencias | A mano (08-04) | No existe | No existe | Integrada |
| División de código | import() a mano |
lazy |
defineAsyncComponent |
@defer y loadComponent |
| Formularios | FormData + validación propia |
Se elige | v-model |
Dos sistemas incluidos |
| Pruebas | Jest + Testing Library | Testing Library | Vue Test Utils | TestBed incluido |
Y las métricas de la misma pantalla, con las cuatro versiones:
| Métrica | JavaScript puro | React | Vue | Angular |
|---|---|---|---|---|
| Líneas de la vista | ~210 | ~130 | ~120 | ~180 |
| Líneas de infraestructura propia | ~60 | 0 | 0 | 0 |
| Dependencias de producción | 0 | 2 | 1 | ~8 (paquetes @angular/*) |
| Peso del motor (comprimido, aprox.) | 0 | ~45 kB | ~35 kB | ~60–90 kB según lo que se use |
| Conceptos previos necesarios | DOM, módulos | JSX, hooks, reconciliación | Plantillas, refs | TS, decoradores, DI, señales, RxJS |
| Tiempo hasta ser productivo | — | Semanas | Días o semanas | Semanas o meses |
| Previsibilidad entre proyectos | Ninguna | Baja | Media | Alta |
Lo que gana Angular, sin adornos: cohesión total. Todo encaja porque lo hizo el mismo equipo. El tipado es de serie y atrapa errores antes de ejecutar. El track obligatorio impide un fallo real. Los servicios inyectados dan estado compartido y capacidad de prueba sin instalar nada. Las migraciones automáticas hacen viables las actualizaciones a diez años vista. Y dos proyectos distintos se parecen, lo que reduce el coste de incorporar a alguien.
Lo que pierde, con la misma franqueza: es el más caro de aprender, con diferencia. TypeScript, decoradores, inyección de dependencias, señales, RxJS y dos sistemas de formularios son mucho antes de la primera pantalla. Es el más verboso: 180 líneas frente a 120 para lo mismo. Es el más pesado en el arranque. Y es el que peor encaja en proyectos pequeños, donde toda esa infraestructura no tiene nada que sostener.
Errores Comunes y Consejos
Olvidar los paréntesis al leer una señal. tarea.titulo en lugar de tarea().titulo compara o accede sobre la función, no sobre el valor. En la plantilla, {{ tareas }} muestra el código de la función. TypeScript detecta muchos de estos casos, pero no todos.
Mutar el valor de una señal. tareas()[0].estado = 'hecha' no dispara nada: las señales comparan con Object.is. Hay que usar update con un map inmutable, como en React.
Olvidar imports en un componente independiente. Si la plantilla usa <app-tarjeta-tarea> o [formGroup] y no está en imports, el error de compilación es claro pero desconcierta al principio. Es el precio de que cada componente declare lo que necesita.
Usar propiedades de clase en lugar de señales. Funciona por Zone.js, pero es el modelo antiguo: pierdes el grano fino, impides la estrategia zoneless y mezclas dos sistemas de reactividad. Para código nuevo, señales.
Llamar a un método en la plantilla en lugar de a un computed. {{ calcularHoras() }} se ejecuta en cada ciclo de detección de cambios, que con Zone.js puede ser muchísimas veces por segundo. computed se cachea. Es la causa clásica de que una aplicación Angular vaya lenta sin motivo aparente.
No suscribirse a un observable. Los observables son perezosos: sin subscribe no ocurre nada. Es la trampa número uno para quien viene de promesas, que se ejecutan al crearse.
No darse de baja. Es la fuga de memoria de 09-03 con otro nombre. Usa el pipe async en la plantilla o takeUntilDestroyed; nunca dejes una suscripción a un flujo infinito sin gestionar.
Poner lógica de negocio en los componentes. En Angular es especialmente evitable porque los servicios existen precisamente para eso, con inyección y pruebas incluidas. La disciplina de 10-01 se aplica igual: el dominio es TypeScript puro, el componente pinta.
Usar ngModel para formularios complejos. Angular recomienda los formularios reactivos para todo lo que no sea trivial: tipado, validación asíncrona, campos dinámicos y pruebas sin renderizar.
Consejo: aprende TypeScript antes que Angular. Intentar aprender los dos a la vez multiplica la confusión, porque no sabrás si un error es del lenguaje o del framework. La lección 11-07 es el punto de partida.
Consejo: empieza con señales y deja RxJS para la capa de datos. El Angular moderno permite escribir casi toda la aplicación con señales y usar observables solo donde aportan —HTTP, WebSocket, secuencias con cancelación—. toSignal es el puente.
Consejo: usa ng generate siempre. Genera la estructura correcta, el fichero de pruebas y los registros necesarios. Escribir componentes a mano es una fuente de errores tontos que la CLI evita.
Ejercicios
Ejercicio 1 · De React a Angular
Traduce este componente de React (de 10-02) a un componente Angular independiente con señales. Mantén el mismo comportamiento y respeta las reglas R6 y R10.
function ResumenPorResponsable({ tareas, responsableActivo }) {
const [expandido, setExpandido] = useState(false);
const porPersona = tareas
.filter((t) => t.estado !== 'hecha')
.reduce((acc, t) => {
const n = t.responsable ?? 'sin asignar';
const previo = acc[n] ?? { tareas: 0, horas: 0 };
acc[n] = { tareas: previo.tareas + 1, horas: previo.horas + t.horasEstimadas };
return acc;
}, {});
const filas = Object.entries(porPersona).sort(([a], [b]) => a.localeCompare(b, 'es'));
const visibles = expandido ? filas : filas.slice(0, 2);
return (
<section>
<h3>Carga por responsable</h3>
<ul>
{visibles.map(([nombre, { tareas: n, horas }]) => (
<li key={nombre} className={nombre === responsableActivo ? 'activo' : ''}>
{nombre}: {n} tareas · {horas} h
</li>
))}
</ul>
{filas.length > 2 && (
<button onClick={() => setExpandido(!expandido)}>
{expandido ? 'Ver menos' : `Ver ${filas.length - 2} más`}
</button>
)}
</section>
);
}Indica en comentarios qué corresponde a useState, a las props y al cálculo derivado.
Ejercicio 2 · Un servicio con HttpClient, señales y cancelación
Escribe un servicio ApiTableroService que:
- Exponga
tareas,cargandoyerrorcomo señales de solo lectura. - Tenga un método
filtrarPor(responsable: string | null)que actualice una señal interna. - Cargue las tareas del servidor cada vez que cambie el responsable, con
debounceTime(300)y cancelando la petición anterior. - Reintente tres veces con medio segundo de espera ante un fallo de red.
- Ante un error definitivo, deje la lista vacía y rellene
errorsin romper el flujo.
Usa toObservable, switchMap, retry, catchError y toSignal. Explica qué operador resuelve la condición de carrera de 10-02 y por qué.
Ejercicio 3 · Validadores para las reglas R3, R6 y R10
Escribe tres funciones puras, sin dependencias de Angular en su lógica, y sus pruebas:
- Un validador
horasEnRangopara la R3 (mayor que 0, máximo 40) que devuelva un error con datos útiles para el mensaje. - Una función
puedeAvanzar(estadoActual, destino)que implemente la R6 usandoSIGUIENTE, y explica por qué no es un validador de formulario. - Un validador de grupo
fechaCoherenteConEstadoque aplique la R10 al revés: si el estado es'hecha', la fecha límite pasada no debe marcar error; si no es'hecha'y la fecha ya pasó, debe devolver un aviso (no un error bloqueante).
Después responde: ¿por qué conviene que estas funciones vivan en dominio/ y no en el componente?
Soluciones
Solución 1
// src/app/componentes/resumen-por-responsable.component.ts
import { Component, input, signal, computed } from '@angular/core';
import { Tarea } from '../dominio/tarea';
interface FilaCarga {
nombre: string;
tareas: number;
horas: number;
}
@Component({
selector: 'app-resumen-por-responsable',
template: `
<section class="resumen">
<h3>Carga por responsable</h3>
<ul>
@for (fila of visibles(); track fila.nombre) {
<li [class.activo]="fila.nombre === responsableActivo()">
{{ fila.nombre }}: {{ fila.tareas }} {{ fila.tareas === 1 ? 'tarea' : 'tareas' }}
· {{ fila.horas }} h
</li>
}
</ul>
@if (filas().length > 2) {
<button type="button" (click)="alternar()">
{{ expandido() ? 'Ver menos' : 'Ver ' + (filas().length - 2) + ' más' }}
</button>
}
</section>
`,
styles: `
.activo { font-weight: 600; }
ul { list-style: none; padding: 0; }
`
})
export class ResumenPorResponsableComponent {
// ← props de React: entradas basadas en señales
tareas = input.required<Tarea[]>();
responsableActivo = input<string | null>(null);
// ← useState de React: señal escribible local
readonly expandido = signal(false);
// ← cálculo derivado durante el renderizado: computed cacheado
readonly filas = computed<FilaCarga[]>(() => {
const porPersona = this.tareas()
.filter((t) => t.estado !== 'hecha') // solo abiertas
.reduce<Record<string, { tareas: number; horas: number }>>((acc, t) => {
const nombre = t.responsable ?? 'sin asignar'; // R8
const previo = acc[nombre] ?? { tareas: 0, horas: 0 };
acc[nombre] = { tareas: previo.tareas + 1, horas: previo.horas + t.horasEstimadas };
return acc;
}, {});
return Object.entries(porPersona)
.map(([nombre, datos]) => ({ nombre, ...datos }))
.sort((a, b) => a.nombre.localeCompare(b.nombre, 'es'));
});
readonly visibles = computed(() =>
this.expandido() ? this.filas() : this.filas().slice(0, 2)
);
alternar(): void {
this.expandido.update((v) => !v); // como setExpandido(v => !v)
}
}Diferencias notables respecto al original:
- Las filas se transforman a un array de objetos con
nombredentro, en lugar de pares[clave, valor]. En la plantilla de Angular, la desestructuración de@fores más limitada que en JSX, y un objeto se lee mejor. track fila.nombrees obligatorio: sin él, el proyecto no compila. En la versión React,keyera un aviso que se puede ignorar.- La lógica de agrupación podría —y probablemente debería— vivir en
dominio/carga.tscomo función pura, dejando el componente solo con la presentación. Con el backlog canónico devuelve: Iván 3 / 25 h, Lucía 1 / 14 h, Marta 1 / 6 h.
Solución 2
// src/app/datos/api-tablero.service.ts
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { switchMap, debounceTime, retry, catchError, tap, of, startWith } from 'rxjs';
import { Tarea } from '../dominio/tarea';
@Injectable({ providedIn: 'root' })
export class ApiTableroService {
private readonly http = inject(HttpClient);
private readonly _responsable = signal<string | null>(null);
private readonly _cargando = signal(false);
private readonly _error = signal<string | null>(null);
readonly cargando = this._cargando.asReadonly();
readonly error = this._error.asReadonly();
readonly tareas = toSignal(
toObservable(this._responsable).pipe(
debounceTime(300),
tap(() => { this._cargando.set(true); this._error.set(null); }),
switchMap((responsable) => {
let params = new HttpParams();
if (responsable) params = params.set('responsable', responsable);
return this.http.get<Tarea[]>('/api/tareas', { params }).pipe(
retry({ count: 3, delay: 500 }), // 07-03: reintentos
tap(() => this._cargando.set(false)),
catchError((fallo) => {
this._error.set(fallo.message ?? 'Error de red');
this._cargando.set(false);
return of([] as Tarea[]); // el flujo NO muere
})
);
}),
startWith([] as Tarea[])
),
{ initialValue: [] as Tarea[] }
);
readonly horasAbiertas = computed(() =>
this.tareas().filter((t) => t.estado !== 'hecha')
.reduce((s, t) => s + t.horasEstimadas, 0)
);
filtrarPor(responsable: string | null): void {
this._responsable.set(responsable);
}
}Qué operador resuelve la condición de carrera: switchMap. Su semántica es «cambia al flujo nuevo y cancela la suscripción al anterior». Cuando Marta pasa de «Iván» a «Lucía», la petición de Iván se cancela en el momento en que llega el valor de Lucía. Al cancelarse la suscripción, HttpClient aborta la petición HTTP subyacente —usa internamente el mismo mecanismo que tu AbortController de 07-03—, así que su respuesta nunca llega al tap ni al toSignal. La carrera es imposible por construcción, no por una comprobación que alguien haya escrito.
Compara las tres soluciones al mismo problema:
| Framework | Solución | Riesgo de olvido |
|---|---|---|
| React | AbortController creado y abortado a mano en el efecto |
Alto: hay que acordarse del return |
| Vue | alLimpiar(() => controlador.abort()) en el watchEffect |
Medio: hay que acordarse de la limpieza |
| Angular | switchMap |
Ninguno: es la semántica del operador |
Dos detalles del código: catchError devuelve of([]) en lugar de relanzar, porque si el error propaga, el flujo termina y filtrarPor deja de funcionar para siempre — es el error clásico de RxJS. Y retry va dentro del switchMap, aplicado a la petición concreta; si estuviera fuera, reintentaría todo el flujo externo, incluido el debounceTime.
Solución 3
// src/app/dominio/validadores.ts
import { AbstractControl, ValidationErrors } from '@angular/forms';
import { Estado, SIGUIENTE, HOY } from './reglas';
/** R3: horasEstimadas > 0 y <= 40. */
export function horasEnRango(control: AbstractControl): ValidationErrors | null {
const valor = Number(control.value);
if (Number.isNaN(valor)) return { horasNoNumericas: { actual: control.value } };
if (valor <= 0) return { horasFueraDeRango: { actual: valor, minimo: 1, maximo: 40 } };
if (valor > 40) return { horasFueraDeRango: { actual: valor, minimo: 1, maximo: 40,
sugerencia: 'Divide la tarea en varias' } };
return null;
}
/** R6: solo se permiten las transiciones declaradas en SIGUIENTE. */
export function puedeAvanzar(actual: Estado, destino: Estado): boolean {
return SIGUIENTE[actual] === destino;
}
/** R10, en forma de aviso: vencida = fecha pasada y estado distinto de 'hecha'. */
export function fechaCoherenteConEstado(grupo: AbstractControl): ValidationErrors | null {
const estado = grupo.get('estado')?.value as Estado | undefined;
const fecha = grupo.get('fechaLimite')?.value as string | undefined;
if (!estado || !fecha) return null;
if (estado !== 'hecha' && fecha < HOY) {
return { tareaVencida: { fechaLimite: fecha, hoy: HOY, bloqueante: false } };
}
return null; // si está 'hecha', una fecha pasada es completamente normal
}Las pruebas, con Jest y sin Angular:
describe('horasEnRango (R3)', () => {
const control = (value: unknown) => ({ value }) as AbstractControl;
test('acepta valores válidos', () => {
expect(horasEnRango(control(1))).toBeNull();
expect(horasEnRango(control(12))).toBeNull();
expect(horasEnRango(control(40))).toBeNull();
});
test('rechaza 0 y negativos', () => {
expect(horasEnRango(control(0))?.['horasFueraDeRango']).toBeDefined();
expect(horasEnRango(control(-3))?.['horasFueraDeRango']).toBeDefined();
});
test('rechaza más de 40 y sugiere dividir', () => {
const error = horasEnRango(control(55));
expect(error?.['horasFueraDeRango'].sugerencia).toContain('Divide');
});
});
describe('puedeAvanzar (R6)', () => {
test('permite las transiciones del diagrama', () => {
expect(puedeAvanzar('pendiente', 'en-curso')).toBe(true);
expect(puedeAvanzar('en-curso', 'hecha')).toBe(true);
});
test('rechaza los saltos y los retrocesos', () => {
expect(puedeAvanzar('pendiente', 'hecha')).toBe(false);
expect(puedeAvanzar('hecha', 'en-curso')).toBe(false);
});
});Por qué puedeAvanzar no es un validador de formulario. Un validador comprueba si un valor introducido es aceptable. La R6 no habla de valores sino de transiciones: depende del estado anterior, que no está en el formulario. Es una regla del modelo, y su sitio natural es el servicio —donde ya la aplica avanzar()— y el botón, que se deshabilita cuando SIGUIENTE[estado] es null. Meter reglas de transición en la capa de formularios es un error de ubicación que acaba duplicando la lógica.
Por qué el dominio va en dominio/. Cuatro razones, y las cuatro las has visto ya en este módulo:
- Se prueba sin framework. Las pruebas de arriba se ejecutan en milisegundos, sin TestBed, sin jsdom y sin renderizar nada.
- Se reutiliza en todas las capas. El componente, el servicio, el enrutador y —si el servidor comparte código— la API usan las mismas funciones.
- Sobrevive al framework. Es exactamente la disciplina de 10-01:
dominio/reglas.jsha sido el mismo fichero en las cuatro versiones de esta pantalla. Cambiar de framework es reescribir la vista, no la aplicación. - Es donde el negocio se lee. Alguien que quiera saber qué reglas rigen Nómada Tareas abre una carpeta, no rebusca por los componentes.
Conclusión
Has visto el cuarto y último enfoque del módulo, y el más distinto de todos.
Sabes qué es Angular como plataforma completa: enrutador, cliente HTTP, dos sistemas de formularios, inyección de dependencias, RxJS, generación de código, entorno de pruebas, internacionalización, SSR y migraciones automáticas, todo oficial y actualizado a la vez. Entiendes qué significa «con opiniones» llevado hasta el final —no eliges herramientas, la estructura viene dada, hay una forma correcta de cada cosa, la verbosidad es deliberada, y la curva es empinada al principio y plana después— y en qué perfil de proyecto compensa: aplicaciones grandes, longevas, con equipos numerosos y rotación de personal.
Tienes lo mínimo de TypeScript para leer el código —anotaciones que desaparecen al compilar, interfaces que describen la forma de un objeto, tipos de unión literal que convierten las reglas R1–R10 en cosas que el compilador impide expresar, genéricos que se leen como «X de Y», y modificadores de acceso con el matiz de que private no es #privado—, con la remisión a 11-07 para aprenderlo en serio. Y sabes qué son los decoradores: metadatos declarados encima de una clase, coherentes con la filosofía de que la configuración viva junto a lo que configura.
Conoces la CLI y lo que dice de las opiniones de Angular —que ng generate component cree un fichero de pruebas por defecto no es un detalle—, y los componentes independientes con sus imports, que hoy sustituyen a los NgModule. Dominas las cuatro formas de enlace de datos con la mnemotecnia de los corchetes hacia dentro y los paréntesis hacia fuera, y [(ngModel)] desmitificado como el v-model de Vue: una propiedad que baja y un evento que sube. Y manejas la sintaxis de control de flujo actual —@if/@else, @for con @empty, @switch— con sus cuatro ventajas sobre las directivas antiguas, y @defer con sus disparadores, su @placeholder, su @loading y su @error, que hace declarativamente todo lo que escribiste a mano en 09-05.
Sabes que el track de @for es obligatorio y que sin él el proyecto no compila: la misma clave estable de tu reconciliar de 06-06, elevada de aviso a error, que es «con opiniones» en su forma más útil. Dominas las señales —signal con set y update, computed cacheado y perezoso, effect con su limpieza— y sabes que, a diferencia de Vue, comparan con Object.is y por tanto la inmutabilidad de 04-07 vuelve a ser obligatoria, con la contrapartida de que una señal nunca se pierde al desestructurar. Conoces Zone.js y por qué existía —comprobarlo todo porque no se sabía qué había cambiado—, y la tendencia zoneless que las señales hacen posible.
Entiendes la inyección de dependencias: servicios con @Injectable({providedIn: 'root'}), inject() que pide un tipo en lugar de importar una instancia, el patrón de señal privada escribible más señal pública de solo lectura que es la encapsulación de 05-03 aplicada al estado, y las cuatro capacidades que compra —sustituir en pruebas, cambiar por entorno, controlar el ámbito, e interceptar—. Y sabes que ya la practicaste en 08-04, cuando pasaste AlmacenEnMemoria al constructor de RepositorioLocal para poder probar sin localStorage: Angular hace de ese patrón la norma y automatiza el cableado.
Tienes lo justo de RxJS: un observable es un flujo de valores en el tiempo, es perezoso y no ocurre nada sin subscribe —la trampa número uno para quien viene de promesas—, pipe encadena operadores, y los siete que aparecen siempre tienen equivalente en algo que ya escribiste: map, filter, retry (tu conReintentos), catchError, debounceTime (tu debounce de 09-02), switchMap (tu AbortController) y takeUntilDestroyed (tu destruir()). Sabes por qué hay que darse de baja —es la fuga de 09-03 con otro nombre— y las tres formas correctas de hacerlo, y tienes el criterio para elegir entre señales y observables, con toSignal y toObservable como puentes.
Conoces el enrutador con carga perezosa vía loadComponent, que es el import() dinámico de 09-05 integrado, con guardas que tu enrutador.js no tenía; los formularios reactivos frente a los de plantilla con la tabla que decide; y validadores que son funciones puras implementando la R2, la R4, la R7 y la R9, con errores que llevan datos para producir mensajes útiles, en la línea de tu ErrorDeValidacion con .campo. Y sabes probar con TestBed, donde la línea providers: [{provide: TareasService, useValue: falso}] es la demostración práctica de qué compra la inyección de dependencias.
Has escrito la cuarta versión de la misma pantalla —~180 líneas, con el dominio en TypeScript pero conceptualmente idéntico a los tres anteriores— y tienes la tabla comparativa completa: Angular gana en cohesión, tipado de serie, track obligatorio, servicios inyectados, migraciones automáticas y previsibilidad entre proyectos; y pierde en curva de aprendizaje, verbosidad, peso y encaje en proyectos pequeños.
Ya has visto los cuatro enfoques con la misma pantalla escrita cuatro veces. Queda la pregunta que da sentido a todo el módulo, y que no tiene una respuesta única: cómo se elige. En la última lección vas a poner las cuatro versiones una al lado de otra con sus métricas, a ordenar los criterios que de verdad importan —producto, equipo, mercado, longevidad, rendimiento, SEO, accesibilidad, coste—, y a descubrir que hay una decisión que suele pesar más que la del framework: dónde se renderiza. Y cerrarás conectando con el proyecto final, que se construye en JavaScript puro y que a partir de ahora será una decisión informada: Elegir el Framework Adecuado.
Curso de JavaScript: De Principiante a Avanzado
Módulo 1: Introducción a JavaScript
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
