Saltar al contenido
Angular•8 min de lectura

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:

  1. Recibe inputs nuevos como resultado de un binding de template (Angular compara referencia con ==).
  2. Un evento se maneja en su sub-árbol: event binding, output o @HostListener en el componente raíz o cualquiera de sus hijos.
  3. Alguien llama markForCheck() sobre él (el AsyncPipe lo hace automáticamente).
  4. 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 v22Disponible, deprecado bajo el nombre DefaultDefault desde v22
Cuándo se revisaSiempre que el traversal llega al componenteSolo con inputs nuevos, eventos en su sub-árbol, markForCheck() o signals del template
RequisitosNingunoNotificar cambios (signals, inmutabilidad, markForCheck())
RiesgoRendimiento: revisa de más en cada eventoUI obsoleta si algo muta sin notificar
Ideal paraHosts de componentes de terceros, islas legacyTodo 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

  1. Corre ng update y lee el diff. La lista de componentes que recibieron changeDetection: ChangeDetectionStrategy.Eager es tu mapa de deuda: cada uno es un componente que hoy depende de la revisión automática.
  2. No añadas Eager para apagar incendios. Si un binding no se refresca, el fix es notificar: signals o markForCheck(). Poner Eager restaura el bug de rendimiento con estilo 2015.
  3. Una semana con provideCheckNoChangesConfig({ exhaustive: true }) en desarrollo. Cazará todas las mutaciones silenciosas antes de que un usuario las encuentre.
  4. Formularios reactivos antes de zoneless. Conecta valueChanges a markForCheck() o migra el estado del form a signals con toSignal(). Los formularios son el sitio número uno donde la UI se queda vieja sin Zone.js.
  5. 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?

Referencias

> Más posts