Change Detection en Angular 22: Eager, OnPush por defecto y cómo encaja Zoneless
Actualizas a Angular 22, corres ng update, y el diff te deja una sorpresa: changeDetection: ChangeDetectionStrategy.Eager añadido a componentes que nunca lo declararon. Esa línea marca el momento en que dos reglas del framework cambian de golpe: Default se renombra a Eager y queda deprecado, y OnPush pasa a ser la estrategia por defecto después de una década.
Vamos a ver qué hace cada estrategia de verdad, por qué el equipo de Angular volteó el default, cómo se comportan en una app zoneless (el default desde v21) y qué haría yo en producción esta semana. Con ejemplos listos para copiar.
De Default a Eager: el renombre que nadie vio venir
Durante años hubo dos estrategias de change detection y todos las conocimos por sus nombres: Default (revisa el componente siempre que el traversal llega a él) y OnPush (revisa solo bajo ciertas condiciones). En Angular 22, Default pasó a llamarse Eager y quedó como alias deprecado, a la espera de eliminación:
// change-detection.constants.ts (simplificado)
enum ChangeDetectionStrategy {
OnPush,
Eager,
Default, // @deprecated: equivalente a Eager, se elimina en futuras versiones
}
"Use the Eager strategy, meaning that the component is checked eagerly when the change detection traversal reaches it, rather than only checking under certain circumstances (e.g. markForCheck, a signal in the template changed, etc)." - Source: ChangeDetectionStrategy - angular.dev
El nuevo nombre describe el comportamiento, no la posición. Default siempre fue una etiqueta perezosa: decía cuál era la opción por defecto, pero no qué hacía. Eager dice la verdad: el componente se revisa con ansias, aunque nadie haya avisado de ningún cambio.
La migración de v22 hace dos cosas si corres ng update: añade changeDetection: ChangeDetectionStrategy.Eager explícito a todo componente que no declarara estrategia (para que se mantenga igual tras el flip del default) y reemplaza Default por Eager en los decoradores donde aún aparezca. - Source: Ninja Squad - Angular 22.0
Guarda esa lista de componentes que recibieron Eager explícito. Es literalmente el mapa de tu deuda de change detection, y la vamos a usar al final.
OnPush por defecto desde v22: qué cambia de verdad
"Until Angular v21, the default strategy was Eager. Since Angular v22, the default strategy is now OnPush!" - Source: Ninja Squad - Angular 22.0
Que OnPush sea el default significa que cualquier componente sin estrategia declarada ahora se salta la revisión salvo que algo lo justifique. Los casos documentados donde OnPush sí ejecuta change detection:
- Recibe inputs nuevos como resultado de un binding de template (Angular compara referencia con
==). - Un evento se maneja en su sub-árbol: event binding, output o
@HostListeneren el componente raíz o cualquiera de sus hijos. - Alguien llama
markForCheck()sobre él (elAsyncPipelo hace automáticamente). - Un signal leído en su template cambia (la notificación estrella de la era zoneless).
Los escenarios concretos, con la documentación oficial en la mano: si el evento ocurre en un componente Eager, Angular recorre el árbol completo pero se salta los sub-árboles OnPush que no recibieron inputs nuevos. Si el evento ocurre dentro de un componente OnPush, se revisa su sub-árbol y sus ancestros (el evento convierte en "sucio" todo lo que está arriba). Y si un padre le pasa un input nuevo a un hijo OnPush, solo ese sub-árbol se revisa. - Source: Skipping component subtrees - angular.dev
Y los dos edge cases que van a morder a alguien de tu equipo:
// dashboard-widget.ts
export class DashboardWidget {
// ❌ Mutar el input por referencia no dispara change detection en OnPush:
// la referencia es la misma y la comparación `==` no detecta nada
refresh(filters: Filters) {
this.filters.apply(filters);
}
// ✅ Si no tienes más remedio que mutar, notifica tú
refreshNotifying(filters: Filters) {
this.filters.apply(filters);
this.cd.markForCheck();
}
}
Si tras la migración algo "no se refresca", antes de culpar a Angular 22 revisa quién dejó de notificar: ese binding viejo casi siempre pertenece a un componente que dependía de Zone.js para enterarse de todo. Ahora esa llamada no existe, y el componente no tiene forma de saber que algo cambió.
Eager vs OnPush: beneficios, costos y cuándo usar cada uno
Eager es la comodidad: cero disciplina. Funciona con código que muta objetos por referencia, con componentes de terceros que no notifican nada y con esos módulos legacy que nadie quiere tocar. El costo pagas en cada evento: Angular revisa el árbol completo aunque el 90% de los componentes no haya cambiado nada. En una pantalla con cientos de componentes, buena parte de tu presupuesto de 16.6ms (el de los 60fps) se va en revisar lo mismo una y otra vez.
OnPush es la disciplina que ahora es gratis: el framework se salta sub-árboles enteros sin que escribas nada, y encaja de forma natural con signals (un signal() leído en el template ya notifica por sí solo). El costo es la trampa de los binding viejos: mutaciones silenciosas, formularios reactivos que cambian sin refrescar, y la tentación de "arreglarlo" poniendo Eager y perdiendo toda la optimización.
Eager (ex Default) | OnPush | |
|---|---|---|
| Estado en v22 | Disponible, deprecado bajo el nombre Default | Default desde v22 |
| Cuándo se revisa | Siempre que el traversal llega al componente | Solo con inputs nuevos, eventos en su sub-árbol, markForCheck() o signals del template |
| Requisitos | Ninguno | Notificar cambios (signals, inmutabilidad, markForCheck()) |
| Riesgo | Rendimiento: revisa de más en cada evento | UI obsoleta si algo muta sin notificar |
| Ideal para | Hosts de componentes de terceros, islas legacy | Todo lo demás, especialmente con signals |
¿Cuándo mantener Eager a propósito? El caso documentado es un componente que actúa de host para componentes de usuario creados dinámicamente (vía ViewContainerRef.createComponent, por ejemplo): si pones OnPush en el host y el hijo no es OnPush-compatible, el hijo deja de refrescarse. En una librería que no controla qué le van a colgar debajo, Eager es la opción honesta. - Source: Zoneless - angular.dev
Para todo lo demás: OnPush. Es la línea base ahora, y pelear contra el default te cuesta trabajo extra en cada componente nuevo.
Change Detection en Zoneless: las nuevas reglas del juego
Desde v21 las apps Angular son zoneless por defecto. Sin Zone.js ya no existe ese guardia que gritaba "revisen todo" en cada setTimeout o evento del DOM. Ahora Angular solo reacciona a notificaciones explícitas: una signal leída en el template que cambió, un markForCheck(), un ComponentRef.setInput(), el callback de un listener vinculado, o la conexión de una vista marcada como sucia. Nada más. - Source: Zoneless - angular.dev
La lista de notificaciones zoneless y las condiciones de OnPush son casi la misma lista. Un componente "OnPush-compatible" es, de facto, un componente zoneless-compatible. Por eso el flip del default a OnPush y la era zoneless son la misma jugada en dos movimientos: primero el framework te obliga a notificar bien, después elimina la red de seguridad que te perdonaba no hacerlo.
Eso sí, hay tres sitios donde la falta de Zone.js se siente:
Formularios reactivos. Los updates del modelo (setValue, patchValue, FormArray.push) actualizan el estado del formulario y emiten observables, pero no programan change detection. Si tu template depende de ese estado, conéctalo:
// profile-form.ts
import { ChangeDetectorRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export class ProfileForm {
private readonly cd = inject(ChangeDetectorRef);
constructor() {
this.form.valueChanges
.pipe(takeUntilDestroyed())
.subscribe(() => this.cd.markForCheck());
}
}
"Reactive forms model updates (setValue, patchValue, FormArray.push, and similar APIs) update form state and emit form observables, but they do not automatically schedule component change detection." - Source: Zoneless - angular.dev
La alternativa moderna: toSignal(this.form.valueChanges) y leer la signal en el template, y te olvidas del markForCheck manual.
Observables de NgZone. onMicrotaskEmpty, onUnstable y onStable nunca emiten en zoneless, e isStable siempre es true. Si los usabas para esperar a que Angular terminara, el reemplazo es afterNextRender (una pasada) o afterEveryRender (condiciones que cruzan varias pasadas).
Testing. El CLI ya genera tests con await fixture.whenStable() en vez de fixture.detectChanges(), y hace bien: detectChanges() fuerza revisiones que Angular no habría programado, así que tus tests dejan de probar el comportamiento real. Para cazar bindings que se actualizan sin notificación, provideCheckNoChangesConfig({ exhaustive: true, interval: 1000 }) en desarrollo lanza la red y te suelta un ExpressionChangedAfterItHasBeenCheckedError en cuanto alguien muta a escondidas.
Si quieres el recorrido completo de la migración (qué buscar, qué eliminar, cómo lidiar con librerías dependientes de Zone.js), en la guía definitiva de Zoneless lo desgloso paso a paso. Y para medir el resultado, el Change Detection analyzer de DevTools que trajo v22.2 (explicado en el post de Angular 22.2) te pinta en rojo cualquier componente que pase de 16.6ms por pasada.
Lo que haría en producción esta semana
- Corre
ng updatey lee el diff. La lista de componentes que recibieronchangeDetection: ChangeDetectionStrategy.Eageres tu mapa de deuda: cada uno es un componente que hoy depende de la revisión automática. - No añadas
Eagerpara apagar incendios. Si un binding no se refresca, el fix es notificar: signals omarkForCheck(). PonerEagerrestaura el bug de rendimiento con estilo 2015. - Una semana con
provideCheckNoChangesConfig({ exhaustive: true })en desarrollo. Cazará todas las mutaciones silenciosas antes de que un usuario las encuentre. - Formularios reactivos antes de zoneless. Conecta
valueChangesamarkForCheck()o migra el estado del form a signals contoSignal(). Los formularios son el sitio número uno donde la UI se queda vieja sin Zone.js. - Mide con el Change Detection analyzer. Ordena componentes por tiempo de la última pasada y mira quién supera los 16.6ms. Los candidatos a refactor aparecen solos.
¿Ya actualizaste a v22? ¿OnPush te rompió algo o tu app ni se enteró del flip?