Módulo 09: Detección de cambios

Objetivos del módulo

  • Explicar qué ocurre desde que cambia un dato hasta que el DOM se actualiza
  • Distinguir las fases de notificación, marcado, planificación y recorrido
  • Entender qué hacía Zone.js, por qué se ha abandonado y qué implica trabajar sin zonas (zoneless)
  • Saber exactamente qué avisa a Angular y qué no
  • Razonar con la estrategia OnPush, que es la predeterminada en Angular 22, y con Eager
  • Predecir qué componentes se revisan en cada situación, y medirlo
  • Diferenciar la pasada dirigida de una señal de la pasada global de markForCheck
  • Usar ChangeDetectorRef: markForCheck, detectChanges, detach y reattach
  • Resolver el error NG0100 y descubrir cambios silenciosos con provideCheckNoChangesConfig
  • Depurar una vista que no se actualiza con ng, Angular DevTools y el panel de rendimiento
  • Migrar código pensado para Zone.js y escribir pruebas que no oculten los errores

1. El problema: mantener la pantalla al día

Una interfaz tiene dos copias de la misma información. Una vive en memoria, en tus variables de TypeScript. La otra vive en el DOM, en forma de textos y atributos que ve el usuario. Si cambias la primera, la segunda no se entera sola:

let total = 3;
const parrafo = document.querySelector('p')!;
parrafo.textContent = `Pedidos: ${total}`;

total = 4; // El párrafo sigue diciendo 3

Con JavaScript sin framework tienes que acordarte de actualizar cada elemento afectado tras cada cambio. En una pantalla pequeña es tedioso, y en una aplicación con cientos de datos conectados es una fuente inagotable de errores: siempre se olvida algún sitio.

La detección de cambios (change detection) es el mecanismo con el que Angular resuelve ese trabajo. Tú declaras en la plantilla qué dato va en cada sitio, y Angular mantiene las dos copias alineadas. Para conseguirlo necesita responder a dos preguntas:

  1. ¿Cuándo debo mirar? Alguien tiene que decirle que algo ha podido cambiar.
  2. ¿Dónde debo mirar? Revisar toda la aplicación por cada clic sería un derroche.

Casi todo este módulo gira en torno a esas dos preguntas. Las respuestas han cambiado mucho en la historia de Angular, y por eso conviene entenderlas en lugar de memorizar reglas.

📘 Concepto: La detección de cambios es el proceso que compara los valores que muestra cada plantilla con los que mostró la última vez y escribe en el DOM las diferencias. En Angular moderno solo se pone en marcha cuando alguien la avisa.


2. Cómo actualiza Angular la vista

Plantillas convertidas en funciones

Como viste en el módulo 03, el compilador convierte cada plantilla en una función. Esa función tiene dos modos: uno de creación, que construye los elementos una sola vez, y otro de actualización, que vuelve a calcular todas las expresiones de la plantilla.

Por cada componente que se muestra en pantalla, Angular mantiene en memoria una vista: una estructura que guarda, entre otras cosas, el último valor de cada enlace. Un enlace (binding) es cada conexión entre una expresión y el DOM. Esta plantilla tiene dos:

<h2 [title]="ayuda()">Hola, {{ nombre() }}</h2>

Actualizar la vista significa ejecutar la función de la plantilla y, para cada enlace, comparar el valor nuevo con el guardado. Si son distintos, escribe en el DOM y guarda el nuevo. Si son iguales, no toca nada.

Esa comparación se hace con Object.is, la misma que usa el código fuente de Angular en bindingUpdated. Es una comparación por identidad, y esto tiene dos consecuencias importantes:

  • Un número o una cadena se comparan por su valor.
  • Un objeto o un array se comparan por referencia. Si le añades un elemento con push, la referencia es la misma y, para Angular, no ha cambiado nada.

Lo recordarás cuando veamos los errores con OnPush.

Una pasada completa

Llamaremos pasada a cada ejecución de la detección de cambios. Una pasada no ocurre al instante de cambiar un dato. Primero Angular se entera, luego reserva un momento y después actúa:

Las siete fases: notificación, marcado, planificación, ApplicationRef.tick, comparación, DOM y ganchos posteriores

Figura 1. El recorrido completo desde que algo avisa hasta que se ejecutan los ganchos posteriores al renderizado.

  1. Notificación. Algo le dice a Angular que hay trabajo: una señal, un evento de plantilla, markForCheck…
  2. Marcado. Angular marca la vista afectada y sus antepasados. Así sabrá luego por qué camino bajar.
  3. Planificación. El planificador reserva una pasada. Lanza una carrera entre requestAnimationFrame y un setTimeout, y actúa con el primero que llegue.
  4. ApplicationRef.tick(). Empieza el recorrido desde la raíz, siempre de padres a hijos.
  5. Comparación. Se ejecutan las plantillas de las vistas que lo necesitan.
  6. DOM. Se escriben solo las diferencias.
  7. Después. Se ejecutan afterEveryRender y afterNextRender, los ganchos que viste en el módulo 03.

La fase 3 explica una propiedad muy útil: varios avisos seguidos producen una sola pasada. Este componente modifica sus señales cuatro veces en el mismo trozo de código:

this.a.set(1);
this.b.set(1);
this.a.set(2);
this.b.set(2);

Al medirlo, la plantilla se ejecuta una sola vez. Angular espera a que tu código termine antes de actuar, de modo que no pagas por los estados intermedios ni el usuario los llega a ver.

💡 Consejo: Por eso no hace falta agrupar a mano las escrituras en señales. Cambia todo lo que necesites en el orden que te resulte natural.

⚠️ Importante: Como la pasada es diferida, justo después de señal.set(...) el DOM todavía no ha cambiado. Si necesitas leer el DOM ya actualizado, por ejemplo para medir un elemento, usa afterNextRender, no un setTimeout con un número mágico.


3. De Zone.js al trabajo sin zonas

Lo que hacía Zone.js

Durante años Angular respondió a la pregunta “¿cuándo debo mirar?” con una biblioteca llamada Zone.js. Su idea es ingeniosa: al cargar la página, sustituye por versiones propias las API asíncronas del navegador, como setTimeout, addEventListener o Promise. Esa técnica se llama parchear (monkey patching). Cuando termina cualquier tarea asíncrona, Zone.js lo sabe y se lo dice a Angular.

Angular no sabía qué había cambiado, solo que algo había terminado. Así que recorría la aplicación entera por si acaso.

Comparación: a la izquierda Zone.js intercepta todas las tareas y lanza una pasada; a la derecha solo los avisos explícitos llegan al planificador

Figura 2. Zone.js vigila todas las tareas. Sin zonas, solo actúan los avisos explícitos.

Era cómodo, porque cualquier propiedad normal de una clase parecía reactiva. Pero tenía costes reales:

  • Pasadas inútiles. Un setInterval que no toca nada visible o un mousemove lanzan igualmente un recorrido.
  • Tamaño. Es código extra en cada descarga. En nuestro proyecto de prueba, el fichero de zone.js ocupa unos 93 kB sin comprimir.
  • Parches globales. Modificar setTimeout para toda la página produce pilas de error más largas y confusas, e interacciones difíciles de depurar con otras bibliotecas.
  • Límites técnicos. Zone.js no puede interceptar un async/await nativo, y obliga a compilar esa sintaxis a una forma más antigua.

Cómo es hoy

Un proyecto creado con ng new no incluye Zone.js. Su app.config.ts ni siquiera menciona la detección de cambios:

// app.config.ts generado por ng new
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes)
  ]
};

No hace falta ningún proveedor porque trabajar sin zonas es el comportamiento predeterminado, tanto en Angular 22 como en la LTS. provideZonelessChangeDetection() sigue existiendo, y lo verás en código de versiones anteriores, pero en un proyecto nuevo sobra. Si necesitas volver a las zonas, añades provideZoneChangeDetection() y pones zone.js en polyfills. Si solo haces lo primero, la aplicación no arranca:

RuntimeError: NG0908: In this configuration Angular requires Zone.js

Qué avisa a Angular

Esta es la tabla más importante del módulo. La documentación oficial enumera los avisos, y hemos comprobado en el laboratorio la mayoría de las filas:

Lo que ocurre ¿Avisa?
Cambia una señal leída en una plantilla Sí
Se dispara un evento enlazado en la plantilla o en host, como (click) Sí
Se llama a ChangeDetectorRef.markForCheck() Sí
AsyncPipe recibe un valor, porque llama a markForCheck() por ti Sí
Se llama a ComponentRef.setInput() Sí
Se vuelve a enganchar una vista que estaba marcada Sí
Asignas una propiedad normal: this.total = 4 No
Termina un setTimeout, una promesa o un fetch No
Llega la respuesta de HttpClient a un subscribe No
Se dispara un addEventListener que has puesto tú No

Fíjate en las cuatro últimas filas. Por sí solas no son un problema: si dentro de ese setTimeout escribes en una señal, la señal avisa. Lo que no avisa es el temporizador en sí.

El error más típico: la propiedad que no se pinta

Este componente carga empleados de la API de EMPRESA. Hemos dejado a propósito la forma de trabajar de otra época:

// empleados-campo.ts: NO funciona
import { HttpClient } from '@angular/common/http';
import { Component, inject } from '@angular/core';

const API = 'http://localhost:3000/api';

interface Empleado {
  empId: number;
  nombre: string;
  apellidos: string;
}

@Component({
  selector: 'app-empleados-campo',
  template: `
    <h2>Con propiedades normales</h2>
    @if (cargando) {
      <p>Cargando empleados...</p>
    } @else {
      <ul>
        @for (e of empleados; track e.empId) {
          <li>{{ e.nombre }} {{ e.apellidos }}</li>
        }
      </ul>
    }
  `,
})
export class EmpleadosCampo {
  private readonly http = inject(HttpClient);
  protected empleados: Empleado[] = [];
  protected cargando = true;

  constructor() {
    this.http.get<{ contenido: Empleado[] }>(`${API}/empleados?tamano=5`).subscribe((pagina) => {
      this.empleados = pagina.contenido;
      this.cargando = false;
      console.log(`Han llegado ${pagina.contenido.length} empleados`);
    });
  }
}

Al ejecutarlo, la consola dice Han llegado 5 empleados, pero la pantalla sigue en “Cargando”. Los datos están en memoria y son correctos. Simplemente, nadie ha avisado a Angular. Este es el desconcierto clásico de quien llega desde tutoriales antiguos: el código parece perfecto, y lo es, pero la vista no se entera.

La solución es que el estado que se muestra viva en señales:

// empleados-senal.ts: la parte que cambia
export class EmpleadosSenal {
  private readonly http = inject(HttpClient);
  protected readonly empleados = signal<Empleado[]>([]);
  protected readonly cargando = signal(true);

  constructor() {
    this.http.get<{ contenido: Empleado[] }>(`${API}/empleados?tamano=5`).subscribe((pagina) => {
      this.empleados.set(pagina.contenido);
      this.cargando.set(false);
    });
  }
}

La plantilla lee cargando() y empleados() en lugar de las propiedades. El resto es igual.

Dos listados con la misma petición: el de propiedades normales sigue en Cargando empleados y el de señales muestra cinco empleados

Figura 3. La misma petición en la misma página. Arriba, propiedades normales; abajo, señales.

Aún se puede escribir menos. toSignal convierte el observable en una señal y gestiona la suscripción por ti (lo verás a fondo en el módulo 15):

export class EmpleadosToSignal {
  protected readonly empleados = toSignal(
    inject(HttpClient)
      .get<{ contenido: Empleado[] }>(`${API}/empleados?tamano=5`)
      .pipe(map((p) => p.contenido)),
    { initialValue: [] },
  );
}

Para cargar datos con señales, la herramienta pensada específicamente es httpResource, que estudiarás en el módulo 13. Y si prefieres un observable en la plantilla, AsyncPipe también avisa, como viste en el módulo 07.

📘 Concepto: Sin zonas, la regla es sencilla: todo dato que se muestra y cambia después de la primera pintura debe avisar. Lo normal es que sea una señal.


4. Estrategias: OnPush y Eager

La sección anterior responde a “¿cuándo mirar?”. Ahora toca “¿dónde?”. Cuando se lanza una pasada, Angular recorre el árbol de componentes desde la raíz, y la estrategia de cada componente decide qué ocurre al llegar a él. Existen dos:

  • Eager: cada vez que el recorrido llega al componente, ejecuta su plantilla, haya cambiado algo o no.
  • OnPush: al llegar, solo ejecuta su plantilla si tiene motivo. Si no, se lo salta, y también a todos sus descendientes.

Con Eager, cada pasada ejecuta plantillas que quizá no han cambiado. Con OnPush, el coste de la pasada depende de lo que ha cambiado y no del tamaño de la aplicación.

OnPush por defecto 🔄 v22.0

🔄 Cambia en Angular 22.0. En la LTS (Angular 21) un componente sin changeDetection usa Default, que equivale a Eager. Si usas la LTS: añade changeDetection: ChangeDetectionStrategy.OnPush a cada componente para tener el mismo comportamiento, o ng generate component nombre --change-detection=OnPush al crearlo.

Un componente que no indica nada es OnPush. Estas dos declaraciones son equivalentes en Angular 22:

@Component({ selector: 'app-tarjeta', template: `...` })
export class Tarjeta {}

@Component({
  selector: 'app-tarjeta',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `...`,
})
export class Tarjeta {}

Un componente OnPush se revisa cuando se da alguno de estos motivos:

Motivo Ejemplo
Una entrada recibe una referencia nueva El padre pasa otro valor a [dato]
Ocurre un evento en su plantilla o en la de un descendiente (click) en un botón suyo
Una señal leída en su plantilla cambia valor.set(1)
Se llama a markForCheck() en él o en un descendiente Directamente o mediante AsyncPipe

Eager: cuando la necesitas

ChangeDetectionStrategy.Eager sustituye al antiguo nombre Default, que está obsoleto. Ambos existen ya en la LTS 21.2 y valen lo mismo:

@Component({
  selector: 'app-legado',
  changeDetection: ChangeDetectionStrategy.Eager,
  template: `<p>{{ total }}</p>`,
})
export class Legado {
  protected total = 0;
}

Sirve sobre todo para código antiguo que aún no se ha adaptado. Si escribes componentes nuevos con señales, no la necesitas. La documentación de Angular cita otro caso: bibliotecas cuyos componentes alojan componentes del usuario creados con ViewContainerRef.createComponent, que no siempre pueden usar OnPush.

⚠️ Importante: Eager no significa “se actualiza solo”. Sin zonas, una propiedad cambiada en un setTimeout no se pinta tampoco en un componente Eager hasta que alguna otra cosa provoque una pasada. Solo hace que, cuando la pasada llegue, se ejecute la plantilla.

Medido: un temporizador con propiedad normal

Dos componentes hermanos hacen lo mismo, setTimeout(() => this.n++, 200), y solo cambia su estrategia. Medimos qué muestran pasado el temporizador, y después de pulsar un botón ajeno a ellos:

Configuración Eager tras el temporizador OnPush tras el temporizador Eager tras el botón OnPush tras el botón
Sin zonas 0 0 1 0
Zone.js, raíz Eager 1 0 1 0

La tabla enseña tres cosas:

  • Sin zonas, el temporizador no provoca nada.
  • Eager se puede actualizar “por casualidad” cuando otra cosa lanza una pasada. Es peor de lo que parece: el dato aparece tarde y en un momento sin relación con su causa. Un error así es muy difícil de reproducir.
  • OnPush con propiedad normal no se actualiza en ningún caso, ni siquiera con Zone.js. Es el comportamiento más severo, pero también el más predecible: o avisas, o no se pinta.

⚠️ Importante: En Zone.js con una raíz OnPush, el Eager tampoco se actualiza tras el temporizador: la pasada sale de la raíz, encuentra un OnPush sin motivo y se detiene ahí. Tenlo en cuenta si conservas Zone.js en una aplicación de Angular 22: un componente sin marcar, incluida la raíz, es OnPush.

ng generate y la estrategia 🔄 v22.0

🔄 Cambia en Angular 22.0. En la LTS (Angular 21) la opción --change-detection admite Default y OnPush, y el valor por defecto es Default. Si usas la LTS: usa --change-detection=OnPush para generar cada componente OnPush; el valor Eager no lo acepta el CLI de la 21.

En Angular 22 la opción --change-detection (alias -c) admite Eager y OnPush, con OnPush por defecto. El CLI no escribe nada en el caso predeterminado, y solo añade la línea cuando pides Eager:

ng generate component tarjeta
ng generate component legado --change-detection=Eager

Qué hace ng update 🔄 v22.0

🔄 Cambia en Angular 22.0. Esta migración solo existe al actualizar a 22. Si usas la LTS: no te afecta; tus componentes ya son Default, es decir, Eager, y puedes pasarlos a OnPush uno a uno cuando quieras.

Al actualizar de 21 a 22, la migración change-detection-eager añade ChangeDetectionStrategy.Eager a tus componentes. Así la aplicación se comporta igual que antes, sin sorpresas. El módulo 28 recorre ese paso a paso. Después, puedes retirar esa línea componente a componente, comprobando cada uno con lo que aprendes aquí.

Los dos errores clásicos de OnPush

Mutar en lugar de sustituir. El padre cambia el contenido del array, pero la referencia sigue siendo la misma:

// El padre muta: la señal no cambia y la entrada del hijo tampoco
this.etiquetas().push('revisado');

// Correcto: un array nuevo es una referencia nueva
this.etiquetas.update((lista) => [...lista, 'revisado']);

El primer caso falla por partida doble: Object.is ve la misma referencia, y la señal tampoco avisa, porque nadie ha llamado a set ni a update. El módulo 05 explica la inmutabilidad con señales.

Esperar que un hijo Eager salve a un padre OnPush. Si el recorrido no entra en el padre, nunca llega al hijo, sea cual sea la estrategia de este. Lo medimos en la siguiente sección con NietoEager.


5. Laboratorio: ver qué se revisa

Hablar de estas reglas en abstracto no ayuda mucho, así que vamos a medirlas. Una tentación es poner un contador en la plantilla, pero tiene una trampa: en modo desarrollo, tras cada pasada Angular vuelve a ejecutar algunas plantillas para comprobar que nada ha cambiado (lo verás en la sección 8), y contaría de más. Esta pequeña utilidad evita el problema:

// revisiones.ts
import { afterEveryRender } from '@angular/core';

const revisados = new Set<string>();

// Se llama desde la plantilla: {{ revisado('Padre') }}
export function revisado(nombre: string): string {
  revisados.add(nombre);
  return '';
}

// Llámala una vez, en el constructor del componente raíz
export function iniciarRegistro(alTerminar: (nombres: string[]) => void): void {
  afterEveryRender(() => {
    alTerminar([...revisados]);
    // En desarrollo, tras cada pasada Angular repite algunas plantillas para
    // comprobar que nada ha cambiado. Limpiamos después para no contarlas.
    queueMicrotask(() => revisados.clear());
  });
}

Cada plantilla se apunta al ejecutarse, y iniciarRegistro entrega la lista al terminar la pasada. El componente raíz escribe el historial con textContent, directamente en el DOM, para que medir no provoque cambios:

// app.ts
import { Component, ElementRef, viewChild } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { iniciarRegistro } from './revisiones';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet],
  template: `
    <router-outlet />
    <pre #historial></pre>
  `,
})
export class App {
  private readonly historial = viewChild.required<ElementRef<HTMLElement>>('historial');
  private pasada = 0;

  constructor() {
    iniciarRegistro((nombres) => {
      if (nombres.length === 0) return;
      this.pasada++;
      this.historial().nativeElement.textContent +=
        `Pasada ${this.pasada}: ${nombres.join(', ')}\n`;
    });
  }
}

El árbol de prueba tiene un Padre Eager con cuatro hijos:

// arbol.ts
import {
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  inject,
  input,
  signal,
} from '@angular/core';
import { revisado } from './revisiones';

@Component({
  selector: 'app-nieto-eager',
  changeDetection: ChangeDetectionStrategy.Eager,
  template: `{{ revisado('NietoEager') }}<small>NietoEager</small>`,
})
export class NietoEager {
  protected readonly revisado = revisado;
}

@Component({
  selector: 'app-hijo-onpush',
  imports: [NietoEager],
  template: `
    {{ revisado('HijoOnPush') }}
    <h3>HijoOnPush</h3>
    <p>dato = {{ dato() }} · campo = {{ campo }}</p>
    <button type="button" (click)="nada()">Clic aquí</button>
    <button type="button" (click)="cambiarCampo()">Cambiar campo (1 s)</button>
    <button type="button" (click)="cambiarYMarcar()">Campo + markForCheck (1 s)</button>
    <app-nieto-eager />
  `,
})
export class HijoOnPush {
  private readonly cdr = inject(ChangeDetectorRef);
  readonly dato = input.required<number>();
  protected campo = 0;
  protected readonly revisado = revisado;

  protected nada() {}

  protected cambiarCampo() {
    setTimeout(() => this.campo++, 1000);
  }

  protected cambiarYMarcar() {
    setTimeout(() => {
      this.campo++;
      this.cdr.markForCheck();
    }, 1000);
  }
}

@Component({
  selector: 'app-hijo-eager',
  changeDetection: ChangeDetectionStrategy.Eager,
  template: `
    {{ revisado('HijoEager') }}
    <h3>HijoEager</h3>
  `,
})
export class HijoEager {
  protected readonly revisado = revisado;
}

// Sin changeDetection: OnPush en 22, Eager en 21
@Component({
  selector: 'app-hijo-sin-indicar',
  template: `
    {{ revisado('HijoSinIndicar') }}
    <h3>HijoSinIndicar</h3>
  `,
})
export class HijoSinIndicar {
  protected readonly revisado = revisado;
}

@Component({
  selector: 'app-hijo-senal',
  template: `
    {{ revisado('HijoSenal') }}
    <h3>HijoSenal</h3>
    <p>valor = {{ valor() }}</p>
    <button type="button" (click)="programar()">Señal del hijo (1 s)</button>
  `,
})
export class HijoSenal {
  protected readonly valor = signal(0);
  protected readonly revisado = revisado;

  protected programar() {
    setTimeout(() => this.valor.update((v) => v + 1), 1000);
  }
}

@Component({
  selector: 'app-padre',
  changeDetection: ChangeDetectionStrategy.Eager,
  imports: [HijoOnPush, HijoEager, HijoSenal, HijoSinIndicar],
  template: `
    {{ revisado('Padre') }}
    <h2>Padre</h2>
    <button type="button" (click)="nada()">Clic aquí</button>
    <button type="button" (click)="programar()">Señal del padre (1 s)</button>
    <app-hijo-onpush [dato]="dato()" />
    <app-hijo-eager />
    <app-hijo-sin-indicar />
    <app-hijo-senal />
  `,
})
export class Padre {
  protected readonly dato = signal(0);
  protected readonly revisado = revisado;

  protected nada() {}

  protected programar() {
    setTimeout(() => this.dato.update((v) => v + 1), 1000);
  }
}

💡 Consejo: Los botones con temporizador producen dos pasadas: la primera por el clic, y la segunda, un segundo después, por lo que haga el temporizador. Es justo lo que nos interesa, porque esta segunda ya no tiene un evento detrás.

Estos son los resultados reales con Angular 22.2:

Acción Vistas revisadas
Carga inicial Todas: Padre, HijoOnPush, NietoEager, HijoEager, HijoSinIndicar, HijoSenal
Clic en Padre Padre, HijoEager
Clic en HijoOnPush Padre, HijoOnPush, NietoEager, HijoEager
Clic en HijoSenal Padre, HijoEager, HijoSenal
Temporizador: señal de HijoSenal HijoSenal
Temporizador: señal de Padre, enlazada a [dato] Padre, HijoOnPush, NietoEager, HijoEager
Temporizador: propiedad normal en HijoOnPush Ninguna, no hay pasada
Temporizador: propiedad + markForCheck Padre, HijoOnPush, NietoEager, HijoEager

Página de prueba con los componentes Padre e hijos y el historial de las once pasadas

Figura 4. La página del laboratorio tras recorrer los escenarios. El historial lo escribe la propia aplicación.

Árbol de componentes donde solo Padre e HijoEager aparecen como revisados tras un clic en Padre

Figura 5. Segunda fila de la tabla: el clic en Padre no entra en los hijos OnPush, y NietoEager queda fuera aunque sea Eager.

Vale la pena leer con calma algunas filas:

  • Clic en Padre: HijoOnPush se salta, y con él NietoEager. Es la confirmación de lo dicho antes: un hijo Eager no sirve si su padre OnPush corta el paso.
  • Clic en HijoSenal: el evento marca HijoSenal y sus antepasados. Padre se revisa por ser antepasado, y HijoEager porque el recorrido ha pasado por allí. Pero HijoOnPush, sin motivo, se salta.
  • Señal de Padre: HijoOnPush entra porque su entrada ha recibido un valor distinto. HijoSenal no entra.
  • Propiedad normal: después de esta fila, el texto sigue en campo = 0. Un clic posterior en Padre tampoco lo arregla. Hasta que se pulsa el botón de markForCheck no aparece el valor acumulado, campo = 2.

La misma prueba en la LTS 🔄 v22.0

🔄 Cambia en Angular 22.0. En la LTS (Angular 21) todo componente sin changeDetection es Eager, así que el clic en Padre revisa las seis vistas. Si usas la LTS: para obtener la tabla anterior, escribe changeDetection: ChangeDetectionStrategy.OnPush en HijoOnPush, HijoSinIndicar y HijoSenal.

Ejecutamos el mismo código, sin tocar nada, en Angular 21.2. La misma acción, un clic en Padre, da esto:

Pasada 2: Padre, HijoOnPush, NietoEager, HijoEager, HijoSinIndicar, HijoSenal

En Angular 22, en cambio, son solo dos vistas. Esta diferencia resume el cambio de valor predeterminado.


6. El marcado de las señales

La fila más llamativa de la tabla es esta: al cambiar la señal de HijoSenal desde un temporizador, solo se revisa HijoSenal. Ni Padre ni HijoEager aparecen, aunque sean Eager. ¿Cómo llega Angular hasta allí sin revisar el camino?

Cada plantilla es un consumidor reactivo: mientras se ejecuta, Angular anota qué señales lee, igual que un computed (lo viste en el módulo 05). Cuando una de esas señales cambia:

  1. La vista consumidora queda marcada para refrescarse.
  2. Sus antepasados no se marcan como sucios. Reciben una marca distinta y más ligera, que solo significa “por aquí abajo hay una vista que refrescar”. En el código fuente la coloca markAncestorsForTraversal.
  3. El planificador reserva una pasada.

Durante esa pasada, Angular baja por el camino señalado sin ejecutar las plantillas intermedias. Es una pasada dirigida. markForCheck funciona de otra forma: marca como sucios la vista y todos sus antepasados, y la pasada se convierte en global. Cada vista marcada se ejecuta, y con ellas cada Eager que se alcance. Un evento de plantilla hace ese mismo marcado.

Comparación de dos árboles: con la señal los antepasados solo se atraviesan, y con markForCheck todos se revisan

Figura 6. Una señal marca un camino. markForCheck marca una cadena de vistas sucias.

  Señal leída en plantilla markForCheck o evento
Vista origen Se refresca Se refresca
Antepasados Solo se atraviesan Se refrescan
Hijos Eager de esos antepasados No se revisan Se revisan
Tipo de pasada Dirigida Global

Una vez que una vista se refresca de verdad, sus hijos se recorren con las reglas normales. Lo vimos en la fila de la señal de Padre: al ejecutar su plantilla, HijoEager se revisa y HijoOnPush también, porque su entrada ha cambiado.

📘 Concepto: Las señales aportan precisión. Angular no necesita buscar qué ha cambiado: el grafo de dependencias se lo dice. Por eso, cuando solo cambia un dato, el coste de la pasada depende de las vistas que leen ese dato y no del número total de componentes.

⚠️ Importante: Solo marca la vista lo que se lee en la plantilla, o en un computed que lea la plantilla. Si lees una señal en un método llamado desde un (click), esa lectura no crea ninguna dependencia de la vista.


7. ChangeDetectorRef: intervenir a mano

La mayor parte del tiempo no lo necesitarás. Cuando sí, ChangeDetectorRef se obtiene con inject y da acceso a la vista del componente actual.

Tres tarjetas: markForCheck es diferido, detectChanges es síncrono y detach excluye la vista del recorrido

Figura 7. Las tres herramientas de ChangeDetectorRef.

markForCheck: “avisa cuando puedas”

Marca la vista y sus antepasados, y programa una pasada. Es diferido. Lo hemos medido así:

this.n = 1;
this.cdr.markForCheck();
console.log(parrafo.textContent);   // '0': el DOM aún no ha cambiado
await fixture.whenStable();
console.log(parrafo.textContent);   // '1'

Es la herramienta adecuada cuando heredas código basado en propiedades y aún no puedes pasarlo a señales. También es lo que usa AsyncPipe por dentro.

detectChanges: “hazlo ahora”

Revisa ya, de forma síncrona, esta vista y sus hijos. No afecta a padres ni a hermanos:

this.n = 2;
this.cdr.detectChanges();
console.log(parrafo.textContent);   // '2'

Tiene usos reales, como cuando necesitas medir un elemento justo después de cambiarlo, pero no agrupa. Diez llamadas son diez revisiones. Además, tampoco actualiza nada que dependa de esa vista fuera de su subárbol.

detach y reattach: “ya te digo yo cuándo”

detach() saca la vista del recorrido y reattach() la devuelve. Mientras está fuera, ninguna pasada entra en ella. Esto sirve para pantallas con datos que llegan muy deprisa, en las que pintar cada dato no aporta nada, porque nadie puede leer cien cambios por segundo.

Hemos comprobado dos detalles que no son evidentes:

  • Una vista desenganchada ignora incluso sus señales. Una señal que se actualiza cada 10 ms no refrescó la vista. Solo lo hizo cuando llamamos a detectChanges(). La vista conserva, eso sí, el valor actual: en cada detectChanges pintó el dato más reciente.
  • Si la desenganchas en el constructor, no se llega a pintar nada hasta tu primer detectChanges. En nuestra prueba el párrafo salió vacío. Espera a la primera pintura con afterNextRender, como en el ejercicio 4.
Herramienta Momento Alcance Úsala para
markForCheck() Diferido Sube hasta la raíz y programa una pasada Código con propiedades o integración con observables
detectChanges() Inmediato Esta vista y sus hijos Necesitar el DOM al momento, o vistas desenganchadas
detach() Inmediato Saca la vista del recorrido Datos a muy alta frecuencia
reattach() Inmediato Devuelve la vista al recorrido Reanudar tras detach

💡 Consejo: Antes de recurrir a detach, prueba a reducir la frecuencia de escritura en la señal. A menudo un buffer o un setInterval que escribe en la señal cada 500 ms es igual de eficaz y bastante más sencillo.


8. El error NG0100 y las comprobaciones de desarrollo

Qué significa

En modo desarrollo, tras cada pasada Angular ejecuta una comprobación de no cambios: repasa las plantillas y verifica que dan el mismo resultado que acaban de dar. Si un enlace ha cambiado, algo ha modificado el estado durante la pasada, de modo que la pantalla ya no refleja los datos. Entonces lanza este error, que solo existe en desarrollo:

ERROR RuntimeError: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed
after it was checked. Previous value: 'Inicial'. Current value: 'Cambiado'.
Expression location: _ErrorEager component.

Este componente lo provoca, porque en ngAfterViewInit su plantilla ya se ha ejecutado:

@Component({
  selector: 'app-error-eager',
  changeDetection: ChangeDetectionStrategy.Eager,
  template: `<p>{{ titulo }}</p>`,
})
export class ErrorEager implements AfterViewInit {
  protected titulo = 'Inicial';

  ngAfterViewInit() {
    this.titulo = 'Cambiado'; // Demasiado tarde: ya está pintado
  }
}

OnPush oculta NG0100 🔄 v22.0

🔄 Cambia en Angular 22.0. En la LTS (Angular 21) un componente sin changeDetection lanza NG0100 en esta situación. En Angular 22 el mismo código falla en silencio, porque es OnPush. Si usas la LTS: con los componentes y la raíz en Default sigues viendo el error. Si los pasas a OnPush, añade provideCheckNoChangesConfig({ exhaustive: true }), que existe igual en 21.

La comprobación de no cambios normal no entra en las vistas OnPush, porque ya no están marcadas cuando se ejecuta. Esto tiene una consecuencia que no es fácil de adivinar. Hemos probado el mismo componente con varias combinaciones:

Versión Raíz App Componente Resultado en pantalla ¿NG0100?
21.2 Sin indicar (Eager) Sin indicar (Eager) Inicial Sí
22.2 Eager Eager Inicial Sí
22.2 Eager Sin indicar (OnPush) Inicial No
22.2 Sin indicar (OnPush) Eager Inicial No
22.2 Sin indicar (OnPush) Sin indicar (OnPush) Inicial No

Lee con atención la columna de pantalla. En todos los casos se muestra el valor equivocado. El error nunca fue el problema, sino el aviso del problema. Un proyecto nuevo de Angular 22, con todos sus componentes OnPush, deja de avisarte. Y la cuarta fila enseña algo más: basta una raíz OnPush para ocultarlo incluso en un componente Eager.

Para recuperar el aviso existe provideCheckNoChangesConfig.

provideCheckNoChangesConfig

Esta función está en developer preview tanto en Angular 22 como en la LTS: se puede usar, pero su forma podría cambiar. Solo actúa en desarrollo: en una compilación de producción no aporta ningún proveedor.

// app.config.ts
import { ApplicationConfig, provideCheckNoChangesConfig } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    provideCheckNoChangesConfig({ exhaustive: true, interval: 1000 }),
    // ...
  ],
};

Tiene dos opciones:

  • exhaustive: true trata todas las vistas como Eager durante la comprobación, de modo que también entra en las OnPush. Con ella, todas las filas de la tabla anterior lanzan NG0100, salvo que estés usando señales. Lo hemos comprobado: ErrorOnPush lanza el error con ella, y la variante con señales no lo lanza nunca.
  • interval repite la comprobación cada cierto número de milisegundos. Solo se admite junto con exhaustive: true.

interval resulta especialmente útil para el error más típico de la sección 3. Si una propiedad cambia sin avisar, no hay pasada, y por tanto tampoco hay comprobación tras ella. Este componente no da ningún error por sí solo:

@Component({
  selector: 'app-campo-mudo',
  template: `<p>Pedidos: {{ pedidos }}</p>`,
})
export class CampoMudo {
  protected pedidos = 0;

  constructor() {
    setTimeout(() => (this.pedidos = 3), 500);
  }
}

Sin configuración, la pantalla dice Pedidos: 0 y la consola está limpia. Con la configuración anterior, al cumplirse el intervalo aparece esto, que señala justo el componente culpable:

ERROR RuntimeError: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed
after it was checked. Previous value: '0'. Current value: '3'.
Expression location: _CampoMudo component.

💡 Consejo: Actívalo mientras migras a OnPush o a un proyecto sin zonas. Es lo más parecido a un detector automático de “esto no se pinta”. Solo tiene sentido en desarrollo, y añade trabajo periódico a tu aplicación.

Cómo se arregla NG0100

El nombre del error confunde, porque invita a “repartir” el cambio con un setTimeout. No lo hagas: solo escondes el problema. Las soluciones reales son:

Situación Solución
Un valor depende de otro Derívalo con computed en lugar de copiarlo en un gancho
Un valor inicial se ajusta en ngAfterViewInit Calcúlalo antes, en el constructor o en ngOnInit
Necesitas un dato que solo existe tras pintar Guárdalo en una señal: la señal pide otra pasada
Un hijo modifica el estado del padre durante la pasada Mueve la escritura a un evento o a afterNextRender

Una señal escrita en ngAfterViewInit no da error y muestra Cambiado. Es válido porque avisa de que hace falta otra pasada, y funciona con cualquier estrategia.


9. Depuración: “mi vista no se actualiza”

Ante una pantalla desfasada, sigue este orden. Casi siempre encontrarás la causa en los dos primeros pasos.

  1. Comprueba el dato. Pon un console.log donde se asigna. Si el valor es el esperado, el problema es de detección de cambios. Si no, es de lógica.
  2. Comprueba si el dato avisa. ¿Es una señal? ¿Se ha asignado con set o update? Si es una propiedad normal, tienes el error de la sección 3.
  3. Comprueba la referencia. Si es un objeto o un array, ¿es uno nuevo, o has mutado el anterior?
  4. Comprueba la cadena de padres. ¿Hay detach en algún antepasado?
  5. Activa provideCheckNoChangesConfig con exhaustive e interval para que Angular señale al culpable.

ng en la consola

En modo desarrollo, Angular publica un objeto ng en la consola del navegador. Dos funciones permiten una prueba muy reveladora:

// En la consola, con un elemento seleccionado en la pestaña Elementos ($0)
const c = ng.getComponent($0);
c.campo = 99;           // La pantalla no cambia
ng.applyChanges(c);     // Ahora sí

Lo hemos probado con nuestro HijoOnPush: el texto seguía en campo = 0 tras la asignación y pasó a campo = 99 con applyChanges, que marca el componente y lanza una pasada. Si applyChanges arregla la pantalla, ya sabes que el dato era correcto y lo que faltaba era el aviso.

Angular DevTools

La extensión de navegador que instalaste en el módulo 01 tiene una pestaña Profiler. Según su documentación, funciona así: inicias la grabación, usas la aplicación y la detienes. Cada barra es una pasada de detección de cambios, y una barra más alta indica que tardó más. Al elegir una barra, ves los componentes y directivas que tomaron parte y el tiempo de cada uno. La vista de gráfico de llama (flame graph) los ordena según la jerarquía. Si marcas la casilla Change detection, se distinguen los que pasaron por detección de cambios en ese fotograma, y los demás, como los OnPush sin cambios, se muestran en gris.

No hay que obsesionarse con el Profiler: resulta útil cuando una pantalla va lenta, y en la depuración de datos desfasados suelen bastar los cinco pasos anteriores.

Panel de rendimiento de Chrome

Angular también puede añadir una pista propia, llamada Angular, en el panel Performance del navegador. Basta con ejecutar ng.enableProfiling() en la consola o, para capturar el arranque, llamar a enableProfiling() antes de bootstrapApplication. Solo funciona en modo desarrollo:

// main.ts
import { enableProfiling } from '@angular/core';

enableProfiling();
bootstrapApplication(App, appConfig).catch((err) => console.error(err));

Esa pista muestra componentes, pasadas de detección de cambios y ganchos del ciclo de vida junto a las entradas habituales del navegador.

provideStabilityDebugging

Una aplicación es estable cuando no tiene trabajo pendiente. Si ApplicationRef.whenStable() no se resuelve nunca, o el renderizado en servidor se queda colgado, añade provideStabilityDebugging() a tus proveedores. Si la aplicación no se estabiliza en 9 segundos, escribe qué tareas lo impiden, con la pila que permite localizarlas en tu código. Según su documentación, no se elimina de las compilaciones de producción, así que úsalo solo de forma temporal.


10. Mover código desde Zone.js

Este apartado es para cuando heredes una aplicación antigua. Su recorrido completo, con ng update, está en el módulo 28.

Qué sustituir

Con Zone.js Sin zonas
Propiedad asignada en subscribe toSignal, o signal + set en el subscribe
Propiedad asignada en setTimeout o setInterval Señal, con limpieza en DestroyRef
Propiedad asignada tras un await Señal
Devolución de llamada de una biblioteca externa Escribir en una señal desde la devolución de llamada
NgZone.onStable, onMicrotaskEmpty afterNextRender o afterEveryRender
NgZone.isStable Quitarlo, porque siempre es true
fixture.detectChanges() en pruebas await fixture.whenStable()

Sin zonas, NgZone sigue existiendo para no romper código antiguo, pero lo hemos comprobado: onStable y onMicrotaskEmpty no emiten nunca, isStable vale true y run(fn) simplemente ejecuta fn. El peligro es que no da ningún error: el código de onStable deja de ejecutarse en silencio.

NgZone.run y runOutsideAngular pueden quedarse. La guía oficial advierte de que quitarlos puede empeorar el rendimiento de las aplicaciones que aún usan Zone.js.

Un ejemplo completo

Este panel funcionaba con Zone.js. Sin zonas, compila sin errores ni avisos, pero no se actualiza y la clase listo no se añade nunca:

// Antes: depende de Zone.js
@Component({
  selector: 'app-panel-antes',
  template: `
    <h2>Ventas: {{ total }}</h2>
    <p>Segundos: {{ segundos }}</p>
  `,
})
export class PanelAntes {
  private readonly http = inject(HttpClient);
  private readonly zona = inject(NgZone);
  protected total = 0;
  protected segundos = 0;

  constructor() {
    this.http
      .get<{ importe: number }[]>('/api/pedidos')
      .subscribe((p) => (this.total = p.length));

    interval(1000)
      .pipe(takeUntilDestroyed())
      .subscribe(() => this.segundos++);

    this.zona.onStable.pipe(take(1)).subscribe(() => {
      document.querySelector('h2')?.classList.add('listo');
    });
  }
}

La versión migrada es más corta. Cada propiedad pasa a ser una señal, y onStable da paso a afterNextRender:

// Después: sin zonas
@Component({
  selector: 'app-panel-despues',
  template: `
    <h2>Ventas: {{ total() }}</h2>
    <p>Segundos: {{ segundos() }}</p>
  `,
})
export class PanelDespues {
  protected readonly total = toSignal(
    inject(HttpClient)
      .get<{ importe: number }[]>('/api/pedidos')
      .pipe(map((p) => p.length)),
    { initialValue: 0 },
  );
  protected readonly segundos = toSignal(interval(1000).pipe(map((n) => n + 1)), {
    initialValue: 0,
  });

  constructor() {
    afterNextRender(() => {
      document.querySelector('h2')?.classList.add('listo');
    });
  }
}

Formularios reactivos

La guía oficial advierte de que actualizar un formulario reactivo con setValue no programa la detección de cambios. Nuestras pruebas muestran un matiz que conviene conocer: depende de lo que leas.

  • nombre.value en la plantilla, tras setValue: no se actualiza. Esto coincide con la guía.
  • correo.invalid en una plantilla que contiene [formControl]: sí se actualiza. La directiva NgControlStatus, que pone las clases CSS ng-valid y similares, lee internamente una señal con el estado del control. Esa lectura ocurre en la vista de tu componente y la suscribe al cambio.

No conviene apoyarse en ese detalle interno. Si muestras datos del formulario en la plantilla, conviértelos en señales de forma explícita, como en el ejercicio 5.

Estabilidad y PendingTasks

La estabilidad importa sobre todo en el renderizado en servidor (módulo 24): el servidor debe saber cuándo ha terminado la aplicación para enviar el HTML. Zone.js lo sabía porque veía todas las tareas. Sin zonas, Angular solo conoce lo suyo, como las peticiones de HttpClient y las navegaciones del router.

Para el trabajo asíncrono propio existe PendingTasks:

private readonly tareas = inject(PendingTasks);
private readonly fichaje = inject(FichajeServicio);
protected readonly entrada = signal('-');

constructor() {
  this.tareas.run(async () => {
    this.entrada.set(await this.fichaje.fichar());
  });
}

Mientras dura la función, la aplicación no se considera estable. Lo hemos medido con una tarea que tarda 100 ms: whenStable() seguía pendiente a 1 ms, y estaba resuelto a 150 ms. PendingTasks también tiene add(), que devuelve una función para cerrar la tarea, útil en casos más complicados.


11. Pruebas sin zonas

TestBed no usa zonas de forma predeterminada. El módulo 26 trata las pruebas a fondo; aquí nos interesa la parte que afecta a la detección de cambios.

La costumbre de las pruebas antiguas era llamar a fixture.detectChanges() tras cada cambio. Eso fuerza una revisión que en producción no ocurrirá, y la guía oficial recomienda evitarlo. Esta es la forma recomendada:

it('muestra la hora de entrada', async () => {
  const fixture = TestBed.createComponent(FichajeBien);
  await fixture.whenStable();
  expect(fixture.nativeElement.textContent).toContain('08:00');
});

whenStable() espera a que la aplicación termine su trabajo, igual que ocurriría en el navegador. Si el componente no avisa, el DOM sigue antiguo y la prueba falla, y eso es lo que queremos.

Esto es lo que medimos con un componente que asigna una propiedad normal cuando se resuelve una promesa de 100 ms, comprobando después de que se haya resuelto:

Estrategia whenStable() y leer el DOM fixture.detectChanges() y leer el DOM Leer la propiedad de la clase
OnPush - -, sin ningún error 08:00
Eager - Lanza NG0100, y el DOM sigue en - 08:00

La última columna es la peligrosa: la propiedad vale 08:00, y una prueba que la compruebe pasa aunque el usuario vea -. Comprueba siempre lo que se ve, es decir, el DOM.

⚠️ Importante: Si el dato llega antes de la primera pintura, una propiedad normal sí se muestra, porque esa primera pasada la lee. Nos ocurrió con una promesa de 10 ms: unas veces se veía - y otras 08:00, según la carga de la máquina. Por eso un error de este tipo puede pasar desapercibido en tu equipo con una API local muy rápida, y aparecer en producción.

Tampoco ayuda fixture.changeDetectorRef.markForCheck(): en OnPush, el texto siguió sin cambiar. La guía oficial lo advierte, y añade que, si una prueba lo necesita, seguramente el componente no es del todo compatible con el trabajo sin zonas.


12. Errores frecuentes

Síntoma Causa Solución
La consola muestra el dato, la pantalla no Propiedad normal sin aviso Pásala a señal
Tras añadir un elemento, la lista no cambia push sobre el array Crea uno nuevo con update
Funciona tras hacer clic en cualquier sitio Componente Eager y dato sin aviso Pásalo a señal; no dejes que dependa de otra pasada
Un hijo Eager no se actualiza Padre OnPush sin motivo Da el aviso desde el hijo con una señal
Tras ng update, nada ha cambiado, pero veo Eager en todos los componentes Migración change-detection-eager Es lo esperado: retíralo poco a poco
Tras quitar Zone.js, onStable no se ejecuta NgZone sin zonas no emite afterNextRender
NG0908 al arrancar provideZoneChangeDetection sin zone.js cargado Añade zone.js a polyfills, o quita el proveedor
Un valor se queda antiguo sin ningún error OnPush oculta NG0100 Activa provideCheckNoChangesConfig({ exhaustive: true })
Después de detach, la señal ya no pinta Una vista desenganchada ignora sus señales Llama a detectChanges() o a reattach()
La vista desenganchada sale vacía detach() en el constructor Hazlo en afterNextRender

13. Ejercicios

Ejercicio 1: Reloj sin zonas

Este reloj se queda parado en la hora inicial. Corrígelo sin usar ChangeDetectorRef, y evita además que el temporizador siga vivo al destruir el componente.

@Component({
  selector: 'app-reloj',
  template: `<p>Son las {{ hora }}</p>`,
})
export class Reloj {
  protected hora = new Date().toLocaleTimeString('es-ES');

  constructor() {
    setInterval(() => {
      this.hora = new Date().toLocaleTimeString('es-ES');
    }, 1000);
  }
}
Ver solución
import { Component, DestroyRef, inject, signal } from '@angular/core';

@Component({
  selector: 'app-reloj',
  template: `<p>Son las {{ hora() }}</p>`,
})
export class Reloj {
  protected readonly hora = signal(new Date().toLocaleTimeString('es-ES'));

  constructor() {
    const id = setInterval(() => {
      this.hora.set(new Date().toLocaleTimeString('es-ES'));
    }, 1000);
    inject(DestroyRef).onDestroy(() => clearInterval(id));
  }
}

setInterval no avisa, pero la señal sí. DestroyRef, que viste en el módulo 03, detiene el temporizador. Sin esa limpieza, seguiría ejecutándose después de cerrar la pantalla.

Ejercicio 2: Las etiquetas que no aparecen

Al pulsar “Añadir” no ocurre nada en pantalla. Explica por qué falla y corrígelo.

@Component({
  selector: 'app-lista-etiquetas',
  template: `
    <ul>
      @for (e of etiquetas(); track e) {
        <li>{{ e }}</li>
      }
    </ul>
    <p>Total: {{ etiquetas().length }}</p>
  `,
})
export class ListaEtiquetas {
  readonly etiquetas = input.required<string[]>();
}

@Component({
  selector: 'app-etiquetas',
  imports: [ListaEtiquetas],
  template: `
    <app-lista-etiquetas [etiquetas]="etiquetas()" />
    <button type="button" (click)="anadir()">Añadir</button>
  `,
})
export class Etiquetas {
  protected readonly etiquetas = signal(['urgente']);

  protected anadir() {
    this.etiquetas().push('revisado');
  }
}
Ver solución
protected anadir() {
  this.etiquetas.update((lista) => [...lista, 'revisado']);
}

El clic sí provoca una pasada y Etiquetas se revisa, pero etiquetas() devuelve el mismo array. Con la misma referencia, Object.is no ve cambios, la entrada de ListaEtiquetas no recibe nada nuevo y ese hijo OnPush se salta. Crear un array nuevo cambia la referencia. En la prueba, la lista pasó de 1 a 2 elementos.

Ejercicio 3: Predice las pasadas

Con el árbol de la sección 5 y sin ejecutar nada, indica qué vistas se revisan si:

  1. Pulsas “Clic aquí” en HijoOnPush.
  2. Pulsas “Señal del hijo” en HijoSenal. Hay dos pasadas: di cuáles son.
  3. Pulsas “Cambiar campo” en HijoOnPush, y un segundo después miras la pantalla. ¿Cuántas pasadas ha habido? ¿Qué dice campo?
Ver solución
  1. Padre, HijoOnPush, NietoEager, HijoEager. El evento marca HijoOnPush y su antepasado Padre. NietoEager entra porque su padre se ha refrescado, y HijoEager porque Padre se ha refrescado. HijoSenal y HijoSinIndicar no tienen motivo.
  2. Primera, por el clic: Padre, HijoEager, HijoSenal. Segunda, por la señal: solo HijoSenal.
  3. Una sola, la del clic: Padre, HijoOnPush, NietoEager, HijoEager. El temporizador incrementa la propiedad sin avisar y no hay segunda pasada, de modo que campo sigue mostrando 0.

Son los resultados medidos en la tabla de la sección 5.

Ejercicio 4: Panel de sensores

Un sensor envía una lectura cada 10 ms. Pintar 100 veces por segundo no aporta nada. Escribe PanelSensor, que cuente las lecturas en una propiedad normal y actualice la pantalla solo cada 500 ms. Debe mostrar el 0 desde el principio. Usa detach.

Ver solución
import { ChangeDetectorRef, Component, DestroyRef, afterNextRender, inject } from '@angular/core';

@Component({
  selector: 'app-panel-sensor',
  template: `<p>Lecturas recibidas: {{ lecturas }}</p>`,
})
export class PanelSensor {
  private readonly cdr = inject(ChangeDetectorRef);
  protected lecturas = 0;

  constructor() {
    afterNextRender(() => this.cdr.detach());

    const entrada = setInterval(() => this.lecturas++, 10);
    const pintado = setInterval(() => this.cdr.detectChanges(), 500);

    inject(DestroyRef).onDestroy(() => {
      clearInterval(entrada);
      clearInterval(pintado);
    });
  }
}

afterNextRender espera a la primera pintura. Si llamaras a detach() en el constructor, el párrafo saldría vacío durante los primeros 500 ms. En la prueba, el texto seguía en Lecturas recibidas: 0 a los 350 ms y había cambiado pasados 650 ms.

Ejercicio 5: La vista previa desfasada

La vista previa no cambia cuando el código modifica el control. Corrígelo con una señal.

@Component({
  selector: 'app-nombre',
  imports: [ReactiveFormsModule],
  template: `
    <input [formControl]="nombre" />
    <p>Vista previa: {{ nombre.value }}</p>
  `,
})
export class Nombre {
  protected readonly nombre = new FormControl('Ana');

  constructor() {
    setTimeout(() => this.nombre.setValue('Luis'), 100);
  }
}
Ver solución
@Component({
  selector: 'app-nombre',
  imports: [ReactiveFormsModule],
  template: `
    <input [formControl]="nombre" />
    <p>Vista previa: {{ valor() }}</p>
  `,
})
export class Nombre {
  protected readonly nombre = new FormControl('Ana');
  protected readonly valor = toSignal(this.nombre.valueChanges, {
    initialValue: this.nombre.value,
  });

  constructor() {
    setTimeout(() => this.nombre.setValue('Luis'), 100);
  }
}

valueChanges emite en cada cambio, y toSignal lo convierte en un aviso para la vista. initialValue es necesario porque valueChanges no emite el valor inicial. La prueba muestra Ana en la versión original y Luis en esta.

Ejercicio 6: Biblioteca externa

MapaExterno es una biblioteca que no sabe nada de Angular. Muestra el zoom actual, y deja de escuchar al destruir el componente.

export class MapaExterno {
  private oyente?: (zoom: number) => void;

  alCambiarZoom(oyente: (zoom: number) => void) {
    this.oyente = oyente;
  }

  simularZoom(zoom: number) {
    this.oyente?.(zoom);
  }

  destruir() {
    this.oyente = undefined;
  }
}
Ver solución
@Component({
  selector: 'app-visor-mapa',
  template: `<p>Zoom actual: {{ zoom() }}</p>`,
})
export class VisorMapa {
  readonly mapa = new MapaExterno();
  protected readonly zoom = signal(1);

  constructor() {
    this.mapa.alCambiarZoom((z) => this.zoom.set(z));
    inject(DestroyRef).onDestroy(() => this.mapa.destruir());
  }
}

Da igual cómo o cuándo llame la biblioteca a tu función, porque la señal hace de puente con Angular. Con Zone.js esta integración dependía de que la biblioteca usara API parcheadas. Ahora funciona siempre.

Ejercicio 7: La prueba que miente

Este componente tiene un error, pero la prueba pasa. Explica por qué, corrige la prueba para que lo detecte y arregla el componente.

@Injectable({ providedIn: 'root' })
export class FichajeServicio {
  fichar(): Promise<string> {
    return new Promise((ok) => setTimeout(() => ok('08:00'), 100));
  }
}

@Component({
  selector: 'app-fichaje',
  template: `<p>Entrada: {{ entrada }}</p>`,
})
export class Fichaje {
  protected entrada = '-';

  constructor() {
    inject(FichajeServicio).fichar().then((hora) => (this.entrada = hora));
  }
}

it('muestra la entrada', async () => {
  const fixture = TestBed.createComponent(Fichaje);
  await fixture.whenStable();
  await new Promise((ok) => setTimeout(ok, 150));
  expect(fixture.componentInstance['entrada']).toBe('08:00');
});
Ver solución

La prueba comprueba la propiedad de la clase, que efectivamente vale 08:00. Pero nadie avisa a Angular, así que la pantalla sigue mostrando Entrada: -. Lo medimos: la propiedad era 08:00 y el DOM, Entrada: -.

La prueba correcta mira lo que ve el usuario, y falla con ese componente:

it('muestra la entrada', async () => {
  const fixture = TestBed.createComponent(Fichaje);
  await fixture.whenStable();
  expect(fixture.nativeElement.textContent).toContain('08:00');
});

El componente se arregla con una señal y PendingTasks, para que whenStable() espere a la promesa:

@Component({
  selector: 'app-fichaje',
  template: `<p>Entrada: {{ entrada() }}</p>`,
})
export class Fichaje {
  private readonly tareas = inject(PendingTasks);
  private readonly fichaje = inject(FichajeServicio);
  protected readonly entrada = signal('-');

  constructor() {
    this.tareas.run(async () => {
      this.entrada.set(await this.fichaje.fichar());
    });
  }
}

Ejercicio 8: Caza al campo mudo

Añade a un proyecto el componente CampoMudo de la sección 8. Configura la aplicación para que Angular te señale el error, identifica el mensaje en la consola, y arregla el componente.

Ver solución
// app.config.ts
provideCheckNoChangesConfig({ exhaustive: true, interval: 1000 }),

Tras un segundo, la consola muestra NG0100 con Previous value: '0'. Current value: '3' y Expression location: _CampoMudo component. El arreglo:

@Component({
  selector: 'app-campo-mudo',
  template: `<p>Pedidos: {{ pedidos() }}</p>`,
})
export class CampoMudo {
  protected readonly pedidos = signal(0);

  constructor() {
    setTimeout(() => this.pedidos.set(3), 500);
  }
}

Con la señal, el mensaje desaparece y la pantalla muestra Pedidos: 3. Conviene retirar interval cuando termines de migrar.

Ejercicio 9 (propuesto): Mide tu propia aplicación

Añade revisado() a tres componentes de un proyecto tuyo, y compara las pasadas de un clic con las de un cambio de señal desde un temporizador. Después, cambia uno de los componentes a Eager, repite la medición y explica la diferencia.

Ejercicio 10 (propuesto): Migración de un servicio

Escribe un servicio CarritoServicio con una propiedad normal lineas y un componente que la muestre usando subscribe y markForCheck. Pásalo después a señales: lineas como signal, el total como computed, y quita ChangeDetectorRef. Comprueba con una prueba y whenStable() que la pantalla se actualiza.

Ejercicio 11 (propuesto): Reanudar un panel

Amplía PanelSensor con un botón “Pausar/Reanudar” que use reattach(). Decide qué ocurre con los temporizadores mientras la vista está en pausa, y comprueba qué pasa si reanudas sin llamar a detectChanges().


Resumen

Concepto API Idea clave
Detección de cambios ApplicationRef.tick() Compara cada enlace con Object.is y escribe las diferencias
Planificación Interna Varios avisos producen una sola pasada, y el DOM cambia después
Sin zonas Predeterminado Solo actúan los avisos explícitos
Volver a zonas provideZoneChangeDetection() Requiere zone.js en polyfills, o da NG0908
Señal en plantilla signal, computed Pasada dirigida: solo se refresca la vista que la lee
OnPush ChangeDetectionStrategy.OnPush Predeterminado en 22; se revisa solo con motivo y corta a sus descendientes
Eager ChangeDetectionStrategy.Eager Se revisa siempre que la alcanzan, pero no lanza pasadas
Avisar a mano markForCheck() Marca hasta la raíz y programa una pasada global
Revisar ya detectChanges() Síncrono, sobre la vista y sus hijos
Excluir del recorrido detach(), reattach() La vista ignora incluso sus señales
Error de desarrollo NG0100 Un valor cambió durante la pasada; OnPush lo oculta
Comprobación ampliada provideCheckNoChangesConfig Developer preview; exhaustive entra en OnPush e interval detecta cambios mudos
Consola ng.getComponent, ng.applyChanges Si applyChanges lo arregla, faltaba el aviso
Perfilado Profiler, enableProfiling() Mide las pasadas y el coste de cada componente
Tras la pintura afterNextRender, afterEveryRender Sustituyen a NgZone.onStable
Estabilidad PendingTasks, whenStable() Registra el trabajo asíncrono propio
Pruebas await fixture.whenStable() Comprueba el DOM, no la propiedad, y no fuerces revisiones