Mentalidad y arquitectura moderna en Angular 22
Mentalidad y arquitectura moderna en Angular 22
Angular cambió más en las últimas cinco versiones que en toda la década anterior. No hablamos de azúcar sintáctico: cambió el modelo mental. El Angular que aprendiste con NgModule, *ngIf, @Input(), constructor(private http: HttpClient) y subscribe() sigue compilando, pero ya no es la forma en que Angular quiere que pienses. Y acá el punto no es la moda: cada una de esas piezas nuevas existe para atacar un problema concreto de rendimiento, acoplamiento o complejidad.
Este capítulo no te enseña las APIs una por una (eso viene en los capítulos siguientes). Te da el marco mental para que el resto del curso tenga sentido. Si arrastrás la mentalidad del Angular clásico a un proyecto v22, vas a escribir código que compila pero que pelea contra el framework: change detection que corre de más, componentes acoplados a módulos, y estado que nadie puede razonar. La calidad en Angular 22 empieza por entender por qué cambió el modelo.
Qué cambió, y por qué no es cosmético
El Angular clásico se apoyaba en tres pilares que hoy dejaron de ser el centro:
- NgModules como unidad de organización y compilación.
- Zone.js como mecanismo para detectar cuándo algo pudo haber cambiado.
- RxJS como respuesta por defecto a cualquier necesidad reactiva, incluso el estado sincrónico más simple.
Cada uno tenía un costo. Los NgModule obligaban a declarar, importar y exportar componentes en un archivo aparte: ceremonia sin valor y un grafo de dependencias difuso donde nada te decía qué necesitaba realmente cada componente. Zone.js parcheaba las APIs del navegador (setTimeout, addEventListener, Promise, XHR) para avisarle a Angular “algo pasó, revisá todo”; el resultado era que la detección de cambios corría más veces de las necesarias, porque Zone no tenía idea de qué estado cambió, solo de que algo asíncrono ocurrió. Y usar RxJS para todo convertía un contador en un BehaviorSubject con async pipe: reactividad de grano grueso para problemas que no la necesitaban.
Angular 22 reemplaza los tres pilares por otros que dan información precisa al framework:
- Standalone por defecto: el componente declara sus dependencias en su propio
imports. No hay módulo intermediario. - Signals: un grafo de dependencias reactivo de grano fino. Angular sabe exactamente qué signal cambió y qué vistas dependen de él.
- Zoneless: sin Zone.js parcheando el navegador. La detección de cambios se dispara por notificaciones explícitas (un signal leído en template que cambia, un event handler,
AsyncPipe), no por “cualquier cosa asíncrona”.
El hilo conductor es uno solo: darle al framework información precisa en lugar de pedirle que adivine. Ese es el cambio mental. Todo lo demás son consecuencias.
Pensar en Angular 22: reactividad de grano fino
La diferencia más profunda es cómo Angular decide qué actualizar en la pantalla. Compará los dos modelos.
En el modelo clásico (Zone.js + change detection por árbol), cuando ocurría cualquier evento asíncrono, Angular marcaba componentes como “sucios” y recorría el árbol de componentes ejecutando la detección de cambios. Con la estrategia por defecto revisaba prácticamente todo el árbol en cada tick; con OnPush podabas ramas, pero seguías razonando en términos de “árboles sucios” y markForCheck(). El costo es concreto: en una app con cientos de componentes, un setTimeout de una librería de terceros podía disparar una pasada completa de CD que reevaluaba expresiones de template en componentes que no tenían nada que ver.
En el modelo de signals, la relación es directa y de grano fino. Cuando un template lee un signal, Angular crea una conexión viva entre ese signal y esa vista. Si el signal cambia, solo se marca esa vista. No hay que recorrer el árbol adivinando: el grafo de dependencias ya sabe quién depende de qué.
flowchart TB
subgraph clasico["Clásico — Zone.js"]
A[Evento async cualquiera] --> B[Zone: algo pasó]
B --> C[Recorrer árbol de componentes]
C --> D[Reevaluar expresiones<br/>en cada vista chequeada]
end
subgraph moderno["Moderno — Signals + zoneless"]
E[signal.set nuevo valor] --> F[Grafo de dependencias<br/>sabe quién lo lee]
F --> G[Marcar solo las vistas<br/>que leen ese signal]
G --> H[Actualizar solo eso]
end
Por eso zoneless es la dirección del framework, no un experimento marginal. Zoneless quita a Zone.js del medio: la detección de cambios ya no se dispara por “algo asíncrono ocurrió”, sino por notificaciones explícitas que el propio framework entiende. Los signals encajan de forma natural en ese modelo porque son la notificación explícita. Zoneless es estable y es el baseline recomendado en Angular 22 (se volvió el default para apps nuevas a partir de v21). En una app zoneless, la detección de cambios se dispara cuando:
- Un signal leído en un template cambia de valor.
- Se ejecuta un event handler enlazado en el template o en el host.
AsyncPiperecibe un valor nuevo (internamente llama amarkForCheck).- Se llama a
ComponentRef.setInputo se marca una vista conmarkForCheck().
La consecuencia práctica para la calidad: si tu estado vive en signals, la app zoneless actualiza exactamente lo que cambió, sin pasadas de CD desperdiciadas. Si tu estado vive en propiedades mutadas a mano y confiás en que Zone lo detecte, en zoneless directamente no se actualiza. El modelo nuevo premia el estado reactivo explícito y castiga la mutación implícita.
Hábitos del Angular clásico a dejar atrás
Esta es la tabla que conviene tener pegada al monitor mientras migrás la cabeza. A la izquierda, el hábito clásico; a la derecha, el equivalente en Angular 22 y la razón técnica.
| Antes (Angular clásico) | Después (Angular 22) | Por qué |
|---|---|---|
@NgModule({ declarations, imports }) | Componente standalone con su propio imports | Dependencias explícitas por componente, menos ceremonia, mejor tree-shaking y lazy loading directo |
*ngIf / *ngFor / *ngSwitch | @if / @for / @switch (control flow) | Integrado en el compilador, sin importar directivas; @for exige track, evitando re-render completo de listas |
@Input() / @Output() decoradores | input() / output() / model() funciones | input() es un signal: se integra al grafo reactivo y se puede usar en computed() |
constructor(private svc: Svc) | private svc = inject(Svc) | Funciona fuera de constructores (funciones, guards, computed), mejor herencia, menos boilerplate |
subscribe() manual + unsubscribe | toSignal(...), httpResource(...), rxResource(...) | Sin fugas de memoria por suscripciones olvidadas; el estado llega como signal listo para el template |
BehaviorSubject para estado sincrónico | signal() / computed() | Grano fino, sin pipe async, sin gestión de suscripción para estado que no es un stream |
ngOnDestroy para limpiar suscripciones | takeUntilDestroyed() | Limpieza atada al ciclo de vida del inyector, sin implementar la interfaz ni un Subject de destrucción |
Carga de imágenes con <img src> a mano | NgOptimizedImage (ngSrc) | lazy loading, srcset y prioridad de LCP automáticos |
No se trata de reescribir todo mañana. Se trata de que, cuando toques un archivo, lo dejes del lado derecho de la tabla. Un antipatrón clásico y su refactor:
// Antes (antipatrón): NgModule + decoradores + subscribe manual
@Component({
selector: 'app-perfil',
template: `
<div *ngIf="usuario">{{ usuario.nombre }}</div>
<ul>
<li *ngFor="let p of proyectos">{{ p.titulo }}</li>
</ul>
`,
})
export class PerfilComponent implements OnInit, OnDestroy {
@Input() usuarioId!: string;
usuario?: Usuario;
proyectos: Proyecto[] = [];
private sub = new Subscription();
constructor(private api: ApiService) {}
ngOnInit() {
this.sub.add(
this.api.getUsuario(this.usuarioId).subscribe((u) => (this.usuario = u)),
);
this.sub.add(
this.api.getProyectos(this.usuarioId).subscribe((p) => (this.proyectos = p)),
);
}
ngOnDestroy() {
this.sub.unsubscribe();
}
}
// Después (mejor): standalone + signals + control flow + httpResource
@Component({
selector: 'app-perfil',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (usuario.value(); as u) {
<div>{{ u.nombre }}</div>
}
<ul>
@for (p of proyectos.value() ?? []; track p.id) {
<li>{{ p.titulo }}</li>
}
</ul>
`,
})
export class PerfilComponent {
// input() es un signal: se integra al grafo reactivo
usuarioId = input.required<string>();
// httpResource re-dispara la petición cuando cambia usuarioId(),
// cancela la anterior si sigue pendiente, y expone value()/isLoading()/error()
private api = inject(ApiService);
usuario = httpResource(() => `/api/usuarios/${this.usuarioId()}`);
proyectos = httpResource(() => `/api/usuarios/${this.usuarioId()}/proyectos`);
}
El segundo componente no implementa OnInit, no implementa OnDestroy, no tiene un Subscription que gestionar, y no puede tener una fuga de memoria por una suscripción olvidada: no hay ninguna. Además, como usuarioId es un input() signal, cambiar el input desde el padre re-dispara automáticamente las dos peticiones. Es menos código, menos superficie de error, y menos acoplamiento al ciclo de vida.
La arquitectura de una app moderna
Sin NgModule, ¿dónde vive la configuración de la app? La respuesta es bootstrapApplication + funciones provide*. El árbol de la aplicación arranca desde un componente raíz standalone, y todo lo transversal (router, HTTP, zoneless) se configura como providers a nivel de aplicación.
// main.ts — el punto de arranque de una app Angular 22
import { bootstrapApplication } from '@angular/platform-browser';
import {
provideZonelessChangeDetection,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { AppComponent } from './app/app.component';
import { rutas } from './app/app.routes';
import { authInterceptor } from './app/core/auth.interceptor';
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
provideBrowserGlobalErrorListeners(),
provideRouter(rutas, withComponentInputBinding()),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
],
});
Fijate en la densidad de decisiones de calidad que caben en ese archivo:
provideZonelessChangeDetection()establece el modelo de detección de cambios sin Zone.js.provideRouter(rutas, withComponentInputBinding())conecta los parámetros de ruta directamente a losinput()del componente: uninput()puede recibir un parámetro de URL sinActivatedRouteni suscripciones.provideHttpClient(withFetch(), withInterceptors([...]))usa la Fetch API y interceptores funcionales (funciones, no clases conNgModule).
El routing moderno es lazy por defecto en la práctica: cada ruta carga su componente con loadComponent, de modo que el código de esa pantalla no entra en el bundle inicial.
// app.routes.ts — rutas lazy, sin módulos
import { Routes } from '@angular/router';
export const rutas: Routes = [
{
path: 'panel',
loadComponent: () =>
import('./panel/panel.component').then((m) => m.PanelComponent),
},
{
path: 'facturacion',
// loadChildren también funciona con un array de rutas, sin NgModule
loadChildren: () => import('./facturacion/facturacion.routes').then((m) => m.rutas),
},
];
El panorama completo de una app moderna se ve así:
flowchart TD
M[main.ts<br/>bootstrapApplication] --> P[Providers de aplicación<br/>provideZonelessChangeDetection<br/>provideRouter · provideHttpClient]
M --> R[AppComponent<br/>standalone raíz]
P --> ROUTER[Router]
ROUTER -->|loadComponent lazy| F1[Feature: Panel<br/>componentes standalone]
ROUTER -->|loadComponent lazy| F2[Feature: Facturación<br/>componentes standalone]
F1 --> S1[Servicios inject<br/>estado en signals]
F2 --> S2[Servicios inject<br/>httpResource / rxResource]
S1 --> CD[Detección de cambios<br/>de grano fino]
S2 --> CD
Las piezas clave del panorama:
- Componentes standalone como unidad básica: cada uno declara sus
imports, suchangeDetection: OnPush, y consume servicios coninject(). - Providers a nivel de aplicación en
bootstrapApplication: configuración transversal explícita, sin módulos. - Lazy routes con
loadComponent/loadChildren: el bundle inicial solo carga lo que la primera pantalla necesita. - Estado en signals dentro de servicios inyectables, consumido por los componentes.
Esta estructura de carpetas por feature y la separación core/shared la desarrollamos en el capítulo 2. Acá lo importante es ver el esqueleto: sin módulos, todo es composición explícita de componentes, providers y rutas.
Los principios de calidad que guían el curso
Todo el curso se apoya en cuatro dimensiones de calidad. Angular 22 no las garantiza solo, pero remueve la fricción para aplicarlas.
SOLID. El Single Responsibility Principle se vuelve natural cuando un componente standalone declara exactamente lo que necesita y delega el estado a un servicio con signals. La inversión de dependencias (la “D”) es literalmente inject(TOKEN): dependés de una abstracción resuelta por el inyector, no de una construcción manual. El nuevo control flow y input()/output() empujan hacia componentes pequeños y componibles en lugar de componentes-Dios con lógica de plantilla incrustada.
Rendimiento. La reactividad de grano fino de los signals más zoneless significa que actualizás solo lo que cambió, no el árbol entero. @for con track reconcilia listas por identidad en lugar de reconstruir el DOM. @defer difiere la carga de bloques pesados. NgOptimizedImage optimiza el LCP. Todo esto lo medimos y lo profundizamos en los capítulos de change detection y Core Web Vitals.
Complejidad. Menos ceremonia (sin módulos, sin subscribe/unsubscribe, sin ngOnInit para todo) significa menos superficie donde esconder bugs. computed() expresa estado derivado de forma declarativa: en vez de recalcular a mano y sincronizar, describís qué es el valor y el framework se encarga del cuándo. Menos código imperativo, menos estados intermedios inconsistentes.
Seguridad. Angular sanitiza por defecto la interpolación ({{ }}) y los property bindings, mitigando XSS sin esfuerzo. HttpClient trae protección XSRF de fábrica. El modelo standalone + lazy no mete secretos en el bundle si no se lo pedís. Esto lo tratamos a fondo en el capítulo de seguridad, pero el punto arquitectónico es que las defensas están en el camino por defecto, no como un opt-in que hay que recordar.
mindmap
root((Calidad en<br/>Angular 22))
SOLID
inject como inversión de dependencias
componentes standalone con SRP
servicios con signals
Rendimiento
grano fino signals + zoneless
for con track
defer y NgOptimizedImage
Complejidad
sin módulos ni boilerplate
computed declarativo
menos ciclo de vida manual
Seguridad
sanitización por defecto
XSRF en HttpClient
sin secretos en el bundle
La idea que atraviesa el curso: el Angular moderno hace que la opción por defecto sea también la de más calidad. Standalone, OnPush, signals, zoneless y control flow no son solo “lo nuevo”: son la ruta de menor resistencia hacia código rápido, testeable y seguro. Pelear contra ellos —seguir con módulos, Zone y subscribe a mano— es elegir voluntariamente más acoplamiento y peor rendimiento.
Un mapa mental para el resto del curso
Antes de seguir, internalizá estas tres reglas. Son la brújula de todo lo que viene:
- El estado es reactivo y explícito. Vive en signals. Si un valor deriva de otros, es un
computed(). No mutás propiedades esperando que “algo” lo detecte. - Las dependencias son explícitas. Componentes declaran sus
imports; servicios se piden coninject(); la configuración transversal se declara enprovide*. Nada implícito vía módulos. - Lo asíncrono no gestiona su propia limpieza a mano.
httpResource/rxResource/toSignaltraen el dato como signal;takeUntilDestroyedlimpia lo que quede. Unsubscribesin plan de limpieza es una fuga esperando ocurrir.
Si estas tres reglas guían cada decisión, la mayoría de los antipatrones de este curso ni siquiera aparecen: se vuelven imposibles de escribir naturalmente.
Checklist
- Tus componentes son
standaloney declaran sus propias dependencias enimports(sinNgModule). - Usás el nuevo control flow (
@if/@for/@switch) en vez de*ngIf/*ngFor/*ngSwitch, y todo@fortienetrack. - Reemplazaste los decoradores
@Input()/@Output()porinput()/output()/model(). - Inyectás dependencias con
inject()en lugar de inyección por constructor. - El estado sincrónico vive en
signal()/computed(), no enBehaviorSubjectconasyncpipe. - Los datos remotos llegan por
httpResource/rxResource/toSignal, sinsubscribe()manual niunsubscribeenngOnDestroy. - La app arranca con
bootstrapApplicationy configura lo transversal conprovideRouter,provideHttpClientyprovideZonelessChangeDetection. - Las rutas de feature cargan con
loadComponent/loadChildren(lazy), no todo en el bundle inicial. - Verificaste que ningún
provideZoneChangeDetectionesté reintroduciendo Zone.js sin querer.
Con la mentalidad clara, el próximo paso es traducirla a estructura: cómo organizar carpetas por feature, qué va en core y en shared, cómo poner límites de dependencia y por qué el barrel file indiscriminado es un antipatrón. Seguí con Organización del proyecto →.