Saltar a contenido

Parte 7: Lifecycle Hooks

Una aplicación pequeña para ver cuándo hace Angular cada cosa con un componente: cuándo lo crea, cuándo lo revisa y cuándo lo destruye. Esos momentos se llaman hooks del ciclo de vida (lifecycle hooks): métodos que Angular llama por ti y donde puedes enganchar tu código. Es una aplicación Angular 22 creada con ng new, con Tailwind CSS y daisyUI, igual que la Parte 5: Pipes App. Tiene dos páginas (Home y About) y un navbar para pasar de una a otra. La página de inicio muestra una tabla con cada hook y su descripción, y además escribe en la consola del navegador una línea de color por cada uno cuando se ejecuta.

Proyecto: 07-lifecycle-hooks/. Sitio publicado: angular-lifecycle.reskyon.com. Ábrelo con la consola del navegador a la vista. Para correrlo en local usa npm start dentro de la carpeta (http://localhost:4200/).

Esta página va creciendo: cada módulo nuevo se agrega al final. Por ahora cubre el orden de los hooks, de los effect y de los callbacks de render, cómo se entera un componente de que cambió un input y la diferencia entre una propiedad normal y una señal.

Qué vas a aprender:

  • El orden de los hooks. Qué se ejecuta primero, qué se ejecuta una sola vez y qué se repite en cada revisión (Módulo LH1).
  • Los inputs y ngOnChanges. Qué recibe el hook, cuándo corre y qué valor tenían el input antes y después (Módulo LH2).
  • Propiedad normal frente a señal. Por qué sin zone.js un campo normal no refresca la pantalla y una señal sí, y qué hooks se repiten (Módulo LH3).

Cada módulo sigue el mismo orden: el problema que resuelve, el código del repositorio, cómo funciona por dentro, errores comunes y preguntas de entrevista con su respuesta.

Esta página es una parte de la guía de estudio; allí están el mapa general y la ruta recomendada. Conviene haber visto antes la Parte 6: Reactive Forms App.


Módulo LH1: El orden de los hooks del ciclo de vida

El problema

Cuando abres una página, Angular crea el componente, le pasa datos, dibuja su plantilla y la revisa una y otra vez mientras algo cambia. Si no sabes en qué momento ocurre cada paso, acabas pidiendo una cosa antes de que exista: leer un dato que todavía no llegó, o tocar un elemento que aún no está en pantalla.

Los hooks son los puntos de ese recorrido donde Angular te avisa. Cada uno es un método con un nombre fijo que escribes en la clase del componente.

El código del repositorio

Los textos viven en un solo lugar, home.constants.ts, y de ahí salen tanto la tabla de la página como las líneas de la consola:

export const NG_LIFECYCLE_HOOKS = {
  ngOnInit: "Runs once, after Angular sets the component's inputs",
  ngOnChanges: 'Runs every time an input property changes (and once before ngOnInit)',
  ngDoCheck: 'Runs on every change detection pass',
  // ... un texto por cada hook, de ngAfterContentInit a onCleanup
}

Para escribir en la consola con color, log.utils.ts tiene una clase pequeña:

export class ConsoleLogger {
  static format = (name: string, message: string = '', color: string = '#bada55') => {
    console.log(`%s %c%s`, name, `color: ${color}; font-weight: bold;`, message)
  }
}

El nombre sale como texto normal y el mensaje en color (verde por defecto). El color va al final para poder omitirlo.

Una página no puede abrir las herramientas del desarrollador (el navegador no lo permite), así que la app muestra el atajo en su lugar. Es un componente reutilizable, console-hint.component.ts, sin inputs: se importa ConsoleHintComponent en la página y se escribe <app-console-hint /> donde se quiera el aviso (está en Home y en About).

home-page.component.ts implementa los hooks y cada uno llama a ConsoleLogger.format con su nombre y su texto (aquí se omiten los comentarios y los campos de los ejemplos de los módulos siguientes):

export class HomePageComponent
  implements
    OnInit,
    OnChanges,
    DoCheck,
    AfterContentInit,
    AfterContentChecked,
    AfterViewInit,
    AfterViewChecked,
    OnDestroy
{
  hooks = Object.entries(NG_LIFECYCLE_HOOKS)

  constructor() {
    ConsoleLogger.format('Constructor', 'Constructor called')
  }

  basicEffect = effect((onCleanup) => {
    ConsoleLogger.format('effect', NG_LIFECYCLE_HOOKS.effect)
    onCleanup(() => {
      ConsoleLogger.format('onCleanup', NG_LIFECYCLE_HOOKS.onCleanup)
    })
  })

  ngOnInit() {
    ConsoleLogger.format('ngOnInit', NG_LIFECYCLE_HOOKS.ngOnInit)
  }
  // ... ngOnChanges, ngDoCheck, ngAfterContentInit, ngAfterContentChecked,
  //     ngAfterViewInit y ngAfterViewChecked, igual que ngOnInit

  afterNextRenderEffect = afterNextRender(() => {
    ConsoleLogger.format('afterNextRender', NG_LIFECYCLE_HOOKS.afterNextRender)
  })

  afterEveryRenderEffect = afterEveryRender(() => {
    ConsoleLogger.format('afterEveryRender', NG_LIFECYCLE_HOOKS.afterEveryRender)
  })

  ngOnDestroy() {
    ConsoleLogger.format('ngOnDestroy', NG_LIFECYCLE_HOOKS.ngOnDestroy)
  }
}

La plantilla recorre hooks con @for y dibuja una fila por cada par nombre y descripción, así que la tabla y la consola nunca se contradicen.

implements no hace falta para que Angular llame al método (lo busca por su nombre), pero así el compilador avisa si falta o si lo escribes mal. effect, afterNextRender y afterEveryRender no son interfaces: son funciones que registran un callback y se llaman al crear el componente.

El orden

Abre la página de inicio y mira la consola. Verás solo los nombres, en este orden (a cada uno lo acompaña su frase):

Constructor
ngOnInit
ngDoCheck
effect
ngAfterContentInit
ngAfterContentChecked
ngAfterViewInit
ngAfterViewChecked
afterNextRender
afterEveryRender

Después, cada vez que algo cambia en la página, se repiten ngDoCheck, ngAfterContentChecked, ngAfterViewChecked y afterEveryRender. Si no cambia nada, no se repiten (la app no usa zone.js, así que Angular no revisa por su cuenta). onCleanup y ngOnDestroy aparecen al salir de la página, y onCleanup también antes de que el effect vuelva a ejecutarse.

El ngOnChanges de HomePageComponent no aparece, porque la página no tiene inputs. Los ngOnChanges que sí salen en la consola son los del componente del título, que sí recibe inputs: se explican en el Módulo LH2.

Hook Cuándo se ejecuta Cuántas veces
constructor Angular crea la instancia, antes que cualquier hook Una
ngOnChanges Cambia un input(); también justo antes de ngOnInit Cada cambio
ngOnInit Ya están asignados los inputs Una
ngDoCheck En cada revisión de cambios Muchas
effect Al empezar y cada vez que cambia una señal que lee; ocurre durante la detección de cambios Una y cada cambio
ngAfterContentInit Quedó inicializado el contenido proyectado (<ng-content>) Una
ngAfterContentChecked Tras cada revisión del contenido proyectado Muchas
ngAfterViewInit Quedaron inicializados la vista del componente y sus hijos Una
ngAfterViewChecked Tras cada revisión de la vista Muchas
afterNextRender La próxima vez que la página se dibuja en el DOM Una
afterEveryRender Cada vez que la página se dibuja en el DOM Muchas
onCleanup Antes de que el effect se repita y cuando se destruye Cada limpieza
ngOnDestroy Justo antes de que Angular destruya el componente Una

Cómo funciona por dentro

Piensa en un restaurante. El constructor es abrir la mesa; ngOnInit es cuando el mesero ya tiene tu pedido y puede empezar a trabajar; ngDoCheck es el mesero pasando a ver si cambió algo en tu mesa. Los hooks After… son «ya se sirvieron los platos». La comparación se queda corta en algo: el mesero pasa cuando él quiere, y Angular revisa la página cada vez que algo puede haber cambiado, sin que tú lo pidas.

Tres ideas que conviene tener claras:

  1. El constructor no es un hook. Es de TypeScript, no de Angular. Corre primero y todavía no hay inputs asignados; por eso el trabajo que depende de ellos va en ngOnInit.
  2. ngOnChanges necesita un input. Se ejecuta cuando cambia una propiedad que el componente padre le pasa. HomePageComponent no tiene ningún input(), así que ese hook nunca se ejecuta aquí. Para verlo hay que darle un input al componente y cambiar su valor desde el padre; el Módulo LH2 lo hace con el componente del título.
  3. Salir de la página destruye el componente. Si vas a About y vuelves a Home, Angular crea una instancia nueva: el constructor y ngOnInit corren otra vez desde cero. Antes de destruirla corre ngOnDestroy, que sirve para limpiar (cancelar suscripciones, quitar listeners).
  4. effect y onCleanup van juntos. El effect se ejecuta cuando cambia una señal que lee. Lo que registres con onCleanup se ejecuta justo antes de la siguiente ejecución, así que sirve para cancelar lo que la anterior dejó pendiente (un setTimeout, por ejemplo).
  5. afterNextRender y afterEveryRender esperan al DOM. Corren cuando la página ya está dibujada, por eso son el lugar para medir un elemento. La primera corre una vez; la segunda, en cada render, así que debe ser ligera.

Errores comunes

  • Hacer en el constructor lo que debe ir en ngOnInit. Pedir datos o leer inputs en el constructor falla, porque aún no están asignados.
  • Escribir trabajo pesado en ngDoCheck, ngAfterContentChecked o ngAfterViewChecked. Corren en cada revisión; un cálculo largo ahí vuelve lenta toda la página.
  • Esperar ngOnChanges sin tener inputs. No se ejecuta, y no es un error.
  • Medir el DOM en ngOnInit o en un effect. Todavía no está dibujado; para eso están afterNextRender y afterEveryRender.
  • Olvidar el implements. Si escribes ngOnInt en vez de ngOnInit, Angular nunca lo llama y nada avisa; con implements OnInit el compilador sí avisa.

Resumen

  • Orden de arranque: constructor, ngOnInit, ngDoCheck, effect, ngAfterContentInit, ngAfterContentChecked, ngAfterViewInit, ngAfterViewChecked, afterNextRender, afterEveryRender.
  • ngOnChanges solo corre si el componente tiene inputs, y lo hace antes de ngOnInit.
  • Los …Init corren una vez; los …Check y ngDoCheck, en cada revisión.
  • Para limpiar: ngOnDestroy al destruir el componente y onCleanup en cada ejecución de un effect.

Preguntas de entrevista

1. ¿Qué diferencia hay entre el constructor y ngOnInit?

Respuesta:

El constructor lo llama JavaScript al crear la instancia y sirve para dejar la clase lista; ngOnInit lo llama Angular una vez que los inputs ya tienen valor. Todo lo que dependa de un input, de una petición inicial o de la plantilla va en ngOnInit, nunca en el constructor.

2. ¿Por qué ngOnChanges no se ejecuta en HomePageComponent?

Respuesta:

Porque ese hook solo se dispara cuando cambia un input recibido del padre, y este componente no tiene ninguno. Si se le agrega un input() y el padre cambia su valor, ngOnChanges se ejecuta, y lo hace también una vez justo antes de ngOnInit.

3. ¿Qué hooks se repiten y por qué hay que tener cuidado con ellos?

Respuesta:

ngDoCheck, ngAfterContentChecked y ngAfterViewChecked corren en cada revisión de cambios. Si el código que pones ahí es lento o modifica el estado, retrasa toda la pantalla; deben ser muy livianos.

4. ¿Qué pasa con el componente al ir a otra ruta y volver?

Respuesta:

El router destruye el componente al salir de la ruta y crea uno nuevo al volver. Se repiten el constructor y toda la cadena de hooks desde el principio, y el estado que estaba en la instancia anterior se pierde.

5. ¿Para qué sirve declarar implements OnInit si Angular llama al método igual?

Respuesta:

Angular busca el método por su nombre, así que funciona sin la interfaz. La interfaz sirve para que el compilador revise que el método existe y está bien escrito; sin ella, un error de tipeo hace que el hook simplemente no se llame.

6. ¿Cuál es la diferencia entre afterNextRender y afterEveryRender?

Respuesta:

afterNextRender corre una sola vez, la próxima vez que la página se dibuja; afterEveryRender corre después de cada dibujo. Las dos esperan a que el DOM esté listo, así que sirven para medirlo o tocarlo, y la segunda debe ser muy ligera porque se repite.

7. ¿Para qué sirve onCleanup dentro de un effect?

Respuesta:

Para registrar una limpieza que Angular ejecuta antes de volver a correr el effect y cuando el effect se destruye. Es el equivalente a ngOnDestroy pero por ejecución: cancela el setTimeout o la petición que la ejecución anterior dejó pendiente.

Para ampliar: ciclo de vida de un componente.

Módulo LH2: ngOnChanges y los inputs

El problema

Un componente hijo recibe datos de su padre con un input(). Cuando el padre cambia ese dato, el hijo a veces necesita saber qué cambió y cuál era el valor anterior: para compararlos, para registrar el cambio o para reaccionar solo si de verdad es distinto. ngOnChanges es el hook que te lo cuenta.

El código del repositorio

El hijo es el componente del título, title.component.ts. Tiene tres inputs y escribe en la consola todo lo que recibe ngOnChanges:

export class TitleComponent {
  title = input<string>()
  subTitle = input<string>()
  link = input<string>()

  ngOnChanges(changes: SimpleChanges) {
    console.log('ngOnChanges called with changes:', changes)
    for (const inputName in changes) {
      const inputValues = changes[inputName]
      console.log(`Previous value for ${inputName} ==> changed:`, inputValues.previousValue)
      console.log(`Current value for ${inputName} ==> changed:`, inputValues.currentValue)
      console.log(`Is first change for ${inputName}? ==>`, inputValues.firstChange)
    }
  }
}

(Aquí se omiten los comentarios y las líneas de separación de la consola.)

El padre, HomePageComponent, tiene una señal con un contador y un computed que arma el texto del subtítulo. Un botón sube el contador:

onChangesCount = signal(0)
onChangesSubTitle = computed(() => `Input changed ${this.onChangesCount()} times`)

changeOnChangesInput() {
  this.onChangesCount.update((count) => count + 1)
}

En la plantilla, el subtítulo del bloque «OnChanges» es el input que cambia:

<app-title title="OnChanges" [subTitle]="onChangesSubTitle()" link="{{ docs.ONCHANGES }}" />
<button (click)="changeOnChangesInput()">Change the input</button>

Qué se ve en la consola

Al abrir la página, cada título (hay uno por bloque) escribe un ngOnChanges con todos sus inputs, porque todos son nuevos:

Previous value for subTitle ==> changed: undefined
Current value for subTitle ==> changed: Input changed 0 times
Is first change for subTitle? ==> true

Al pulsar el botón, el título del bloque «OnChanges» escribe solo el input que cambió (title y link no aparecen porque siguen igual):

Previous value for subTitle ==> changed: Input changed 0 times
Current value for subTitle ==> changed: Input changed 1 times
Is first change for subTitle? ==> false

Un clic más y el anterior pasa a ser «1 times» y el actual «2 times».

Cómo funciona por dentro

Cada vez que Angular revisa la plantilla del padre, mira el valor que le pasa a cada input y lo compara con el que le pasó la vez anterior. Si es distinto, guarda el cambio en un objeto (SimpleChanges) y llama a ngOnChanges antes de revisar el hijo. Por eso:

  1. changes solo trae lo que cambió. Es un objeto con una entrada por input cambiado; la clave es el nombre del input.
  2. Cada entrada (SimpleChange) tiene tres datos: previousValue, currentValue y firstChange.
  3. La primera vez corre antes de ngOnInit. Ahí previousValue es undefined y firstChange es true: es el valor con que Angular crea el componente.
  4. Compara por referencia. Si el padre pasa un objeto y luego lo modifica por dentro (sin crear uno nuevo), Angular ve el mismo objeto y no avisa.
  5. Funciona también con input() de señales, como aquí. Para reaccionar a un input de señal, lo habitual hoy es leerlo en un computed o un effect; ngOnChanges sigue siendo útil cuando necesitas el valor anterior.

Errores comunes

  • Esperar ngOnChanges por un campo que cambia dentro del propio componente. Solo reacciona a inputs que el padre asigna.
  • Mutar un objeto o un arreglo que se pasa como input. La referencia no cambia y el hook no se ejecuta; hay que pasar uno nuevo.
  • Leer previousValue en la primera llamada. Es undefined; revisa firstChange antes de compararlo.
  • Poner trabajo pesado dentro. Corre cada vez que cambia un input.

Resumen

  • ngOnChanges(changes: SimpleChanges) corre antes de ngOnInit y cada vez que el padre cambia un input.
  • changes trae solo los inputs que cambiaron, con previousValue, currentValue y firstChange.
  • Compara por referencia y solo mira inputs, no campos internos.

Preguntas de entrevista

1. ¿Qué recibe ngOnChanges y qué contiene?

Respuesta:

Recibe un objeto SimpleChanges con una entrada por cada input que cambió en esa revisión. Cada entrada es un SimpleChange con el valor anterior (previousValue), el nuevo (currentValue) y si es la primera vez (firstChange).

2. ¿Cuándo se ejecuta por primera vez ngOnChanges?

Respuesta:

Justo antes de ngOnInit, cuando Angular asigna los valores iniciales de los inputs. En esa llamada firstChange es true y previousValue es undefined. Si el componente no tiene inputs, nunca se ejecuta.

3. ¿Por qué ngOnChanges no se dispara si modifico un objeto que paso como input?

Respuesta:

Porque Angular compara por referencia: si el objeto es el mismo, para él no hay cambio, aunque por dentro tenga otros valores. Para que el hook se ejecute hay que pasar un objeto nuevo (por ejemplo con el operador de propagación).

4. Si ya uso input() de señales, ¿sigo necesitando ngOnChanges?

Respuesta:

Normalmente no: se lee el input dentro de un computed (para derivar un valor) o de un effect (para un efecto secundario), y se actualizan solos. El hook sigue funcionando con inputs de señales, y es útil si necesitas comparar el valor anterior con el nuevo.

Para ampliar: ngOnChanges en la documentación.

Módulo LH3: propiedad normal y señal

El problema

En una app con zone.js, casi cualquier cosa que cambie un campo (un clic, un setTimeout, una petición) hace que Angular vuelva a revisar la pantalla. Esta app no usa zone.js (zoneless): Angular solo revisa cuando algo se lo pide. Entonces surge una duda concreta: si cambio un campo normal desde un setTimeout, ¿se ve el cambio? ¿Y si cambio una señal?

El código del repositorio

En HomePageComponent hay dos propiedades con el mismo valor inicial: una normal y una señal. Dos botones las cambian, y un setTimeout del constructor las cambia a los dos segundos:

propertyValue = 'Homero Thompson'
propertyValueChanged = 'Homero Simpson'

tradProperty = this.propertyValue
signalProperty = signal(this.propertyValue)

constructor() {
  setTimeout(() => {
    ConsoleLogger.format('setTimeout', HOME_TEXTS.SET_TIMEOUT_EXAMPLE)
    this.signalProperty.set(this.propertyValueChanged)
    this.tradProperty = this.propertyValueChanged
  }, 2000)
}

changeTradProperty() {
  this.tradProperty =
    this.tradProperty === this.propertyValue ? this.propertyValueChanged : this.propertyValue
}
changeSignalProperty() {
  this.signalProperty.set(
    this.signalProperty() === this.propertyValue ? this.propertyValueChanged : this.propertyValue,
  )
}

(Aquí se omite el resto del constructor y los comentarios.) La plantilla muestra las dos ({{ tradProperty }} y {{ signalProperty() }}), los botones, la lista de hooks que se repiten y, en un aviso, el texto de HOME_TEXTS.SET_TIMEOUT_EXAMPLE.

Qué se ve

Cambio ¿Se actualiza la pantalla?
Botón «Change Traditional Property» Sí, porque el clic le pide a Angular una revisión
Botón «Change Signal Property» Sí
setTimeout que solo cambia la propiedad normal No: la pantalla sigue con el valor anterior
setTimeout que cambia la señal y la normal Sí, y se ven las dos al valor nuevo

Cada cambio que sí se refleja vuelve a ejecutar ngDoCheck, ngAfterContentChecked, ngAfterViewChecked y afterEveryRender. No se ejecutan ngOnInit, ngOnChanges, afterNextRender ni el effect (que no lee esa señal).

Cómo funciona por dentro

Piensa en el timbre de una casa. Con zone.js, cada cosa que ocurre (un setTimeout, una petición) toca el timbre sola. Sin zone.js, nadie lo toca, salvo que el cambio venga de un evento de la plantilla (un clic) o de una señal.

  1. Un campo normal es invisible para Angular. Cambia en memoria, pero nada avisa. Con un clic funciona porque el evento ya pide una revisión.
  2. signal.set() marca la vista como pendiente. Angular sabe qué plantillas leen esa señal y las refresca, venga el cambio de donde venga.
  3. Al refrescar se redibuja toda la plantilla. Por eso, cuando la señal cambia, también se ve el valor nuevo de la propiedad normal: ya había cambiado en memoria y solo esperaba a que la pantalla se redibujara. Si quitas la línea del signal.set(), la pantalla sigue con el valor viejo.

La comparación se queda corta en algo: la propiedad normal no «se actualiza sola» con la señal; solo aprovecha que la pantalla se redibuja. Si nada la redibuja, el cambio no se ve.

Errores comunes

  • Cambiar un campo normal desde un setTimeout, una promesa o una suscripción y esperar que se vea en una app sin zone.js. Usa una señal.
  • Creer que la propiedad normal «funciona» porque se ve bien en un ejemplo. Puede verse bien solo porque otra señal provocó el redibujado.
  • Pensar que el effect corre en cada cambio. Solo corre cuando cambia una señal que él lee.

Resumen

  • Sin zone.js, Angular revisa la pantalla por un evento de la plantilla, por una señal o por otra señal de aviso explícita; un setTimeout no basta.
  • signal.set() marca la vista y la refresca; el refresco redibuja toda la plantilla.
  • Con cada cambio que se refleja se repiten ngDoCheck, ngAfterContentChecked, ngAfterViewChecked y afterEveryRender.

Preguntas de entrevista

1. ¿Por qué cambiar un campo normal dentro de un setTimeout no actualiza la pantalla sin zone.js?

Respuesta:

Porque nada le avisa a Angular que algo cambió. Con zone.js, la librería intercepta el setTimeout y dispara una revisión al terminar; sin ella, Angular solo revisa cuando se lo piden un evento de la plantilla, una señal o una llamada explícita.

2. ¿Por qué, si en el mismo setTimeout cambio también una señal, se ve el valor nuevo de la propiedad normal?

Respuesta:

Porque signal.set() marca la vista como pendiente y la revisión siguiente redibuja toda la plantilla, no solo el pedazo que usa la señal. La propiedad normal ya tenía el valor nuevo en memoria; lo único que faltaba era que la pantalla se redibujara.

3. ¿Qué ventaja tiene una señal sobre una propiedad normal en una app sin zone.js?

Respuesta:

Angular sabe qué plantillas leen la señal, así que se refresca exactamente lo que depende de ella desde cualquier origen (un evento, un temporizador, una petición). Con una propiedad normal, el cambio solo se ve si algo más provoca la revisión.

Para ampliar: Angular sin zone.js.