Lección 4 de 413 min
Detección de cambios y rendimiento
Cómo Angular decide qué volver a pintar: Zone.js, el modo zoneless, OnPush, @defer e hidratación.
Un panel de gestión con cientos de filas, gráficos y un reloj que avanza cada segundo. Cada vez que algo cambia, Angular tiene que decidir qué partes de la pantalla actualizar. Si revisa todo cada vez, la app se arrastra; si revisa de menos, la pantalla muestra datos viejos.
En esta lección verás cómo toma Angular esa decisión: qué es la detección de cambios, qué papel juegan Zone.js y el modo zoneless, cómo OnPush y las señales reducen el trabajo, y cómo @defer, el renderizado en el servidor y la hidratación hacen que la primera carga sea más rápida.
Cuándo revisa Angular la vista
Qué es la detección de cambios
El proceso con el que Angular compara los datos con la vista y actualiza el DOM.
- Sentido:
- De la raíz a las hojas
Cuando un dato cambia (llega una respuesta del servidor, el usuario pulsa un botón), la pantalla tiene que reflejarlo. La detección de cambios es el proceso con el que Angular recorre el árbol de componentes, de la raíz a las hojas, evalúa los bindings de cada plantilla y, si un valor es distinto del anterior, actualiza solo ese trozo del DOM.
Detrás hay dos preguntas: cuándo lanzar una pasada (lo decide Zone.js o, sin él, las notificaciones) y qué componentes revisar en cada pasada (lo deciden la estrategia de cada componente y las señales). Optimizar consiste en responder «lo menos posible» a las dos.
Úsalo cuando
Entenderla te ayuda cuando una vista no se actualiza o cuando la app va lenta en pantallas con muchos componentes.
Ten en cuenta
En modo desarrollo, Angular comprueba dos veces cada pasada; si un binding cambia durante la propia revisión, verás el error ExpressionChangedAfterItHasBeenCheckedError. Suele indicar que modificas estado en un sitio inadecuado, como un getter o ngAfterViewInit.
<!-- Angular guarda el último valor de cada binding -->
<h2>{{ producto().nombre }}</h2> <!-- "Taza" → "Taza": no toca el DOM -->
<span>{{ total() }} €</span> <!-- 12 → 18: actualiza este texto -->
<button [disabled]="agotado()">Comprar</button>Zone.js
La librería que avisa a Angular cada vez que termina algo asíncrono.
- Se activa con:
- provideZoneChangeDetection()
Durante años, Angular ha usado Zone.js para saber cuándo revisar la vista. Zone.js «parchea» las APIs asíncronas del navegador (setTimeout, las promesas, los eventos del DOM, las peticiones de red) para enterarse de cuándo termina cada tarea, y entonces avisa a Angular, que lanza una pasada desde la raíz.
La ventaja es que funciona sin que hagas nada: cambias una propiedad normal en cualquier callback y la vista se actualiza. El precio es que Angular revisa mucho más de lo necesario (cualquier temporizador de una librería provoca una pasada), que Zone.js añade peso al bundle y que las trazas de error se vuelven más difíciles de leer.
Úsalo cuando
Lo encontrarás en la mayoría de las aplicaciones existentes. Para sacar de su vigilancia un trabajo continuo, como una animación, envuélvelo en NgZone.runOutsideAngular().
Ten en cuenta
Zone.js no puede interceptar el async/await nativo, así que en las apps que lo usan la CLI convierte esa sintaxis a una forma más antigua. Y una librería que use temporizadores sin parar puede provocar pasadas continuas.
export class Oferta {
mensaje = "Oferta válida";
constructor() {
setTimeout(() => {
// Zone.js avisa a Angular al terminar el temporizador.
// Ojo: solo se pinta si el componente es Eager.
this.mensaje = "Oferta caducada";
}, 5000);
}
}Detección de cambios zoneless
Sin Zone.js: Angular solo revisa cuando algo le avisa de forma explícita.
- Por defecto:
- En proyectos nuevos desde la v21
En el modo zoneless, Angular no vigila las tareas asíncronas. Programa una detección de cambios cuando recibe una notificación: cambia una señal que lee una plantilla, se dispara un listener de plantilla como (click), alguien llama a markForCheck() (el pipe async lo hace por ti) o se asigna un input con setInput().
Según la documentación oficial, es el modo por defecto en los proyectos nuevos desde la v21; en la v20 se activaba con provideZonelessChangeDetection(). Ganas un bundle más ligero, menos pasadas inútiles y trazas más limpias. A cambio, cambiar una propiedad normal dentro de un setTimeout ya no actualiza la vista: ese dato tiene que ser una señal.
Úsalo cuando
En proyectos nuevos, siempre. En los existentes, cuando tus componentes ya usan señales, OnPush o el pipe async: entonces la migración suele ser sencilla.
Ten en cuenta
Para migrar, quita zone.js de los polyfills en angular.json y revisa el código que modifica propiedades en callbacks asíncronos. Algunas librerías antiguas dan por hecho que Zone.js existe.
// En la v20, o para dejarlo explícito en app.config.ts:
// providers: [provideZonelessChangeDetection(), provideRouter(routes)]
export class Oferta {
mensaje = signal("Oferta válida");
constructor() {
// La señal avisa a Angular: funciona con y sin Zone.js
setTimeout(() => this.mensaje.set("Oferta caducada"), 5000);
}
}Revisar menos: OnPush y señales
Saber cuándo revisar no basta: en una pasada desde la raíz, Angular podría revisar cientos de componentes que no han cambiado. Las estrategias de detección de cambios y las señales le permiten saltárselos.
OnPush (y Eager)
Permite a Angular saltarse un componente y todos sus hijos si nada los ha marcado.
- Por defecto:
- Desde la v22
- Antes «Default»:
- Eager
Cada componente tiene una estrategia de detección de cambios. Con Eager (el antiguo Default, que sigue existiendo pero está obsoleto), Angular revisa el componente en cada pasada que llega hasta él. Con OnPush, lo salta junto con todo su subárbol, salvo que algo lo haya marcado: un input que recibe un valor nuevo (comparado con Object.is, es decir, por referencia), un evento manejado en él o en sus hijos, una señal que lee su plantilla o una llamada a markForCheck().
Según la documentación oficial, desde la v22 OnPush es la estrategia por defecto, y la migración automática añade ChangeDetectionStrategy.Eager a los componentes que dependían del comportamiento anterior. En proyectos más antiguos verás changeDetection: ChangeDetectionStrategy.OnPush escrito a mano en los componentes optimizados.
Úsalo cuando
En casi todos los componentes, sobre todo en listas largas y pantallas con muchos widgets. Encaja de forma natural con señales y datos inmutables.
Ten en cuenta
OnPush no detecta mutaciones: si el padre hace lista.push(nuevo), la referencia no cambia y el hijo no se revisa. Crea un array nuevo ([...lista, nuevo]) o usa señales.
@Component({
selector: "app-fila-pedido",
// Desde la v22 es el valor por defecto; antes había que escribirlo
changeDetection: ChangeDetectionStrategy.OnPush,
template: `{{ pedido().id }}: {{ pedido().total }} €`,
})
export class FilaPedido {
pedido = input.required<Pedido>();
}
// En el padre: un objeto nuevo marca la fila; mutarlo, no
this.pedidos.update((ps) => ps.map((p) => (p.id === id ? { ...p, total } : p)));Señales y detección de cambios
Una señal sabe qué plantillas la leen, así que Angular puede revisar solo esas.
Cuando una plantilla lee una señal, como {{ total() }}, Angular registra esa dependencia. Si la señal cambia, marca ese componente concreto para revisión y, en modo zoneless, programa una pasada. Ya no depende de que «algo asíncrono haya terminado»: sabe qué cambió y dónde se usa.
Con OnPush la ganancia es mayor: Angular atraviesa los ancestros sin revisarlos y revisa solo el componente que lee la señal. Puedes comprobarlo en el simulador de abajo con el evento «Cambia una señal de Contador».
Úsalo cuando
Para cualquier estado que se muestre en pantalla, sobre todo en apps zoneless: guarda los datos en signal(), deriva con computed() y la vista se mantiene al día sola.
Ten en cuenta
Solo cuentan las señales que se leen en la plantilla, directamente o a través de un computed que la plantilla lee. Si lees la señal en un método y guardas el resultado en una propiedad normal, Angular pierde la pista.
@Component({
selector: "app-contador-carrito",
template: `<span>{{ total() }} €</span>`,
})
export class ContadorCarrito {
// Cuando cambie, Angular revisa solo este componente
total = inject(Carrito).total;
}Interactivo
¿Qué componentes revisa Angular?
Elige cómo se detectan los cambios, cambia la estrategia de cada componente (Eager, la antigua «Default», u OnPush) y lanza un evento.
- AppRevisado
- CabeceraRevisado
- BuscadorRevisadoOrigen
- CatalogoRevisado
- TarjetaRevisado
- CarritoRevisado
- ContadorRevisado
Revisados: 7 de 7 componentes
El (click) es un listener de plantilla: marca a Buscador y a todos sus ancestros (Cabecera y App), aunque sean OnPush. Desde ahí Angular baja revisando los Eager y salta las ramas OnPush que nadie marcó.
Cargar menos: @defer
La otra cara del rendimiento es la primera carga: cuanto menos JavaScript haya que descargar antes de pintar, antes ve algo el usuario.
@defer
Carga una parte de la plantilla, y su código, solo cuando hace falta.
- Disparador por defecto:
- on idle
@defer { <app-comentarios /> } saca de la carga inicial los componentes, directivas y pipes del bloque: el compilador los pone en un archivo JavaScript aparte que solo se descarga cuando se cumple un disparador. La página inicial pesa menos y se pinta antes.
Los disparadores se escriben con on: idle (cuando el navegador está libre, el valor por defecto), viewport (cuando el bloque entra en pantalla), interaction (al hacer clic o pulsar una tecla), hover, immediate y timer(2s). Con when condición lo controlas tú, y con prefetch on … descargas el código antes de mostrarlo: @defer (on interaction; prefetch on idle).
Úsalo cuando
Para lo pesado que no se ve al entrar: los comentarios al final de un artículo, un gráfico en una pestaña secundaria, un editor de texto enriquecido, un mapa.
Ten en cuenta
Solo se separa lo que es standalone y no se usa fuera de bloques @defer en el mismo componente; si no, se carga igualmente con el resto. Y no difieras lo que se ve nada más entrar: provocarías saltos en la maquetación.
<app-articulo [id]="id()" />
@defer (on viewport; prefetch on idle) {
<app-comentarios [articulo]="id()" />
} @placeholder {
<p>Comentarios</p>
}@placeholder, @loading y @error
Lo que se muestra antes, durante y si falla la carga diferida.
@placeholder es lo que se ve antes de que se cumpla el disparador; @loading, lo que se ve mientras se descarga el código; y @error, lo que aparece si la descarga falla. Todos son opcionales, y su contenido se carga de forma normal, no diferida.
Para evitar parpadeos, @placeholder (minimum 500ms) mantiene el marcador un tiempo mínimo, y @loading (after 100ms; minimum 1s) solo aparece si la carga tarda más de 100 ms y, cuando aparece, se queda al menos un segundo. Así una carga rápida no enseña un «Cargando…» que desaparece al instante.
Úsalo cuando
Usa un @placeholder del mismo tamaño que el contenido final para que la página no salte, y añade @loading cuando la carga pueda notarse.
Ten en cuenta
Sin una referencia explícita, los disparadores viewport, interaction y hover observan el @placeholder, que debe tener un único elemento raíz. Si no hay placeholder, no tienen nada que observar.
@defer (on interaction) {
<app-editor-rico [(texto)]="borrador" />
} @placeholder (minimum 300ms) {
<textarea placeholder="Escribe tu reseña…"></textarea>
} @loading (after 100ms; minimum 1s) {
<p>Preparando el editor…</p>
} @error {
<p>No se pudo cargar el editor.</p>
}Pintar antes: SSR e hidratación
Renderizado en el servidor (SSR)
El servidor envía el HTML ya pintado, en lugar de una página vacía.
- Siglas de:
- Server-Side Rendering
- Añadir:
- ng add @angular/ssr
En una SPA normal, el navegador recibe un <app-root> vacío y tiene que descargar y ejecutar el JavaScript antes de mostrar nada. Con SSR (Server-Side Rendering), un servidor Node.js ejecuta Angular en cada petición y devuelve el HTML completo: el usuario y los buscadores ven el contenido enseguida.
Se activa al crear el proyecto (ng new --ssr) o después con ng add @angular/ssr. También puedes prerenderizar, es decir, generar el HTML al compilar, las páginas que no cambian, y elegir el modo de cada ruta en app.routes.server.ts.
Úsalo cuando
Para páginas públicas que deben aparecer en buscadores o cargar rápido en móviles: tiendas, blogs, fichas de producto.
Ten en cuenta
En el servidor no hay window, document ni localStorage: el código que los usa debe ir en afterNextRender o comprobar la plataforma. Y necesitas un servidor que ejecute Node.js, salvo que prerenderices todas las páginas.
export const serverRoutes: ServerRoute[] = [
// HTML generado al compilar
{ path: "", renderMode: RenderMode.Prerender },
// Solo en el navegador: zona privada
{ path: "cuenta", renderMode: RenderMode.Client },
// El resto, HTML en cada petición
{ path: "**", renderMode: RenderMode.Server },
];Hidratación
El navegador reutiliza el HTML del servidor y le conecta la lógica, sin repintar.
Cuando el JavaScript llega a una página renderizada en el servidor, Angular puede borrar ese HTML y pintarlo de nuevo (con un parpadeo) o hidratarlo: recorrer el DOM existente, asociarlo a sus componentes y añadir los listeners. La hidratación hace lo segundo y se activa con provideClientHydration().
La repetición de eventos (event replay) graba además los clics que el usuario hace antes de que la app esté lista y los reproduce después, para que no se pierdan. Según la documentación actual, viene activada junto con la hidratación incremental; en versiones anteriores se añadía con withEventReplay().
Úsalo cuando
Siempre que uses SSR o prerenderizado: es lo que evita el parpadeo y el trabajo repetido. Los proyectos creados con --ssr ya la traen configurada.
Ten en cuenta
El HTML del servidor y el del cliente deben coincidir. Si manipulas el DOM a mano (con innerHTML o librerías que lo tocan directamente) o pintas algo distinto en cada lado, como la hora exacta, Angular avisa de un desajuste. Como último recurso, el atributo ngSkipHydration excluye un componente.
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
// Hidratación (incremental y con repetición de eventos)
provideClientHydration(),
// En versiones anteriores: provideClientHydration(withEventReplay())
],
};Hidratación incremental
Hidrata cada bloque @defer cuando hace falta, no toda la página de golpe.
Con la hidratación normal, toda la página se hidrata al llegar el JavaScript, aunque el usuario nunca toque el pie de página. La hidratación incremental combina SSR y @defer: el servidor pinta el contenido completo de los bloques, pero el navegador no descarga su código ni los hidrata hasta que se cumple un disparador hydrate.
Los disparadores son los de @defer con el prefijo hydrate: hydrate on viewport, hydrate on interaction, hydrate on idle, hydrate when condición o hydrate never, que deja el bloque como HTML estático. Según la documentación actual, viene activada por defecto con provideClientHydration(); en versiones anteriores había que añadir withIncrementalHydration().
Úsalo cuando
En páginas largas con secciones interactivas que el usuario quizá no use: fichas de producto con reseñas, landings con formularios al final, artículos con widgets.
Ten en cuenta
Sin SSR no hay nada que hidratar, así que los disparadores hydrate no tienen efecto y se aplican los normales. Y un bloque con hydrate never no responde a clics: resérvalo para contenido de verdad estático.
<!-- El servidor pinta las reseñas; su código llega al verlas -->
@defer (hydrate on viewport) {
<app-resenas [producto]="id()" />
} @placeholder {
<p>Reseñas</p>
}
<!-- Texto legal: nunca se hidrata -->
@defer (hydrate never) {
<app-pie-legal />
}Pruébalo
En una app con SSR, desactiva JavaScript en las herramientas para desarrolladores del navegador y recarga: verás el contenido igualmente, aunque los botones no respondan. Eso es lo que reciben los buscadores y lo que ve el usuario mientras llega el JavaScript.
Interactivo
Zone.js frente a zoneless
Activa o desactiva opciones para compararlas cara a cara.
| Característica | Zone.js | Zoneless |
|---|---|---|
| Quién avisa a Angular | Zone.js, que parchea las APIs asíncronas | Las señales, los eventos de plantilla y markForCheck() |
| Qué provoca una pasada | Cualquier tarea asíncrona que termina | Solo esas notificaciones |
| Propiedad normal cambiada en un setTimeout | Se pinta, si el componente se revisa | No se pinta: usa una señal |
| Peso | Añade Zone.js al bundle | Más ligero |
| Por defecto en | Proyectos creados antes de la v21 | Proyectos nuevos desde la v21 |
Ponlo en práctica
Saber qué es cada cosa es la mitad del trabajo. La otra mitad es reconocer cuándo usarla.
Interactivo
¿Qué usarías?
Lee cada situación y elige la opción que mejor la resuelve. Fallar aquí no cuenta: es para practicar.
Los artículos de tu blog terminan con una sección de comentarios muy pesada que la mayoría de lectores nunca llega a ver.
Para recordar
- La detección de cambios responde a dos preguntas: cuándo revisar (Zone.js o notificaciones) y qué revisar (las estrategias y las señales).
- Zone.js avisa tras cualquier tarea asíncrona; en zoneless, el modo por defecto en proyectos nuevos, solo avisan las señales, los eventos de plantilla y
markForCheck(). - OnPush, la estrategia por defecto desde la v22, salta subárboles enteros, pero no detecta mutaciones: usa objetos nuevos o señales.
@defersaca código de la carga inicial y lo descarga con un disparador;@placeholdery@loadingevitan saltos y parpadeos.- SSR envía HTML ya pintado, la hidratación lo reutiliza y la hidratación incremental activa cada bloque solo cuando hace falta.
Comprueba lo aprendido
Cuestionario
Responde las 5 preguntas para completar la lección.
1.¿Qué hace Angular en una pasada de detección de cambios?
2.En una app con Zone.js, ¿cómo sabe Angular que debe lanzar una pasada?
3.Un componente OnPush recibe un array por input y el padre hace
this.lista.push(nuevo). ¿Qué ocurre?4.Si escribes
@defer { … }sin indicar disparador, ¿cuándo se carga el bloque?5.¿Qué hace la hidratación en una app con SSR?