Skip to main content

Lifecycle Hooks

Angular lifecycle hooks are conceptually similar to React class component lifecycle methods but differ from the useEffect hook approach.

1. Key Lifecycle Hooks​

Angular calls methods with fixed names at key moments from creation to destruction of a component (or directive). Just implements the corresponding interface (such as OnInit) on the class and define the method.

Call Order​

OrderHookTimingFrequency
1constructorOn instance creation. DI injection and field initializationOnce
2ngOnChangesWhen an input property changes (before ngOnInit the first time)On every change
3ngOnInitInitialization after input initial values are setOnce
4ngDoCheckOn every change detection (for custom detection logic)Every cycle
5ngAfterContentInitAfter projected content (ng-content) is initializedOnce
6ngAfterContentCheckedAfter projected content is checkedEvery cycle
7ngAfterViewInitAfter the component's own view and child views are initializedOnce
8ngAfterViewCheckedAfter the view is checkedEvery cycle
9ngOnDestroyJust before destructionOnce

ngOnInit​

The most commonly used hook. Because it runs after input values are settled, it suits initial data fetching and initialization that depends on inputs. The standard practice is to limit the constructor to DI and field initialization and put the logic in ngOnInit.

@Component({
selector: 'app-user-detail',
template: `<p>{{ user()?.name }}</p>`,
})
export class UserDetailComponent implements OnInit {
userId = input.required<string>();
user = signal<User | null>(null);

private http = inject(HttpClient);

ngOnInit(): void {
this.http.get<User>(`/api/users/${this.userId()}`).subscribe((u) => this.user.set(u));
}
}

ngOnDestroy​

Called just before the component is destroyed. Release things that aren't cleaned up automatically here, such as timers, event listeners, WebSockets, and RxJS subscriptions.

export class ClockComponent implements OnInit, OnDestroy {
now = signal(new Date());
private timerId?: ReturnType<typeof setInterval>;

ngOnInit(): void {
this.timerId = setInterval(() => this.now.set(new Date()), 1000);
}

ngOnDestroy(): void {
clearInterval(this.timerId);
}
}

ngOnChanges​

When a value passed from the parent to an @Input changes, you receive the before and after values as SimpleChanges.

export class PriceComponent implements OnChanges {
@Input() amount = 0;

ngOnChanges(changes: SimpleChanges): void {
if (changes['amount'] && !changes['amount'].firstChange) {
console.log(`${changes['amount'].previousValue} -> ${changes['amount'].currentValue}`);
}
}
}

When you use signal-based input(), ngOnChanges is not called (the design doesn't use SimpleChanges). Instead, create derived values with computed() or run side effects with effect(). This is recommended for new code.

export class PriceComponent {
amount = input(0);
withTax = computed(() => Math.round(this.amount() * 1.1));

constructor() {
effect(() => console.log('amount changed:', this.amount()));
}
}

ngAfterViewInit​

Called after the component's own view and child components are initialized. Elements obtained via viewChild() / @ViewChild can reliably be accessed at this point. Use it to initialize third-party libraries that manipulate the DOM directly (such as charts).

export class ChartComponent implements AfterViewInit {
canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');

ngAfterViewInit(): void {
new Chart(this.canvas().nativeElement, { /* ... */ });
}
}

ngAfterContentInit​

Called after child content projected via ng-content (contentChild() / @ContentChild) is initialized. Use it in components where the parent receives children, such as tabs and accordions.

@Component({
selector: 'app-tabs',
template: `<ng-content />`,
})
export class TabsComponent implements AfterContentInit {
tabs = contentChildren(TabComponent);

ngAfterContentInit(): void {
this.tabs()[0]?.activate();
}
}

Comparison with React​

AngularReact (class)React (function)
ngOnInitcomponentDidMountuseEffect(fn, [])
ngOnChangescomponentDidUpdateuseEffect with a dependency array
ngOnDestroycomponentWillUnmountCleanup function returned by useEffect
ngAfterViewInitcomponentDidMount (after DOM refs)useLayoutEffect / ref callback

The correspondence is not exact. React's useEffect is a declaration of a side effect that "checks the dependency array on every render and re-runs", bundling initialization, update, and cleanup into one hook. In Angular, methods are separated by moment, and re-running side effects is expressed with Signal effect() or RxJS rather than hooks. Also, Angular hooks are called for a single instance of a component, and the function body is not re-executed on every re-render.

2. Change Detection​

Change detection is the mechanism that reflects changes in application state to the DOM.

Zone.js-based Change Detection​

Traditionally, Zone.js patches asynchronous APIs such as setTimeout, DOM events, Promise, and XHR / fetch, and notifies Angular that "something may have changed" every time one completes. On notification, Angular walks the component tree from the root, compares template expression values with the previous ones, and updates the DOM.

It is convenient, but the whole tree is walked even for unrelated events (such as mouse movement), which becomes a cost in large applications.

OnPush Change Detection Strategy​

With ChangeDetectionStrategy.OnPush, the component (and its descendants) is checked only in the following cases.

  • An input property's reference changed (changes to an object's contents don't count)
  • An event handler in the component's own template ran
  • A Signal read in the template changed
  • The async pipe emitted a new value
  • markForCheck() was called
@Component({
selector: 'app-product-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<h3>{{ product().name }}</h3><p>{{ count() }}</p>`,
})
export class ProductCardComponent {
product = input.required<Product>();
count = signal(0);
}

Signals make it easy to benefit from OnPush, and making OnPush the default for new components is recommended.

Using markForCheck()​

When you update the internal state of an OnPush component asynchronously without Signals or the async pipe, mark it with ChangeDetectorRef.markForCheck() to say "check this component next time".

@Component({ changeDetection: ChangeDetectionStrategy.OnPush, /* ... */ })
export class LegacyComponent implements OnInit {
private cdr = inject(ChangeDetectorRef);
message = '';

ngOnInit(): void {
someLegacyApi.onMessage((m: string) => {
this.message = m;
this.cdr.markForCheck(); // Marks it and its ancestors as needing a check
});
}
}
MethodBehavior
markForCheck()Marks itself and its ancestors dirty so they are checked in the next change detection cycle
detectChanges()Runs change detection on this component and its children immediately
detach() / reattach()Detaches from / reattaches to the change detection tree

Differences from React's Virtual DOM​

When state changes, React re-runs the component function, compares (diffs) the old and new virtual DOM, and applies the result to the real DOM. Angular compiles templates and generates update instructions that "compare against the previous value and update the DOM" per expression. It has no virtual DOM and directly updates only the DOM nodes corresponding to the changed expressions.

AspectReactAngular
What is re-runThe entire component functionOnly the template update instructions
Unit of comparisonVirtual DOM treeTemplate expression values
Optimization toolsmemo / useMemo / useCallbackOnPush / Signals
Update triggerCalling setStateZone.js notification / Signal change

With the introduction of Signals, Angular can track which view depends on which Signal, and is moving from walking the whole tree toward updating only the changed views.

3. Zoneless Angular (Experimental)​

This is a mode that performs change detection without Zone.js. It was introduced experimentally in v18, became developer preview in v20, and then stable in v20.2. Without Zone.js you get benefits such as a smaller bundle, more readable stack traces, native async/await, and easier coexistence with other frameworks.

app.config.ts
import { provideZonelessChangeDetection } from '@angular/core';

export const appConfig: ApplicationConfig = {
providers: [provideZonelessChangeDetection()],
};

Also remove zone.js from polyfills in angular.json.

In Zoneless mode, change detection is scheduled only when one of the following occurs.

  • A Signal read in the template was updated
  • markForCheck() was called
  • The async pipe received a new value
  • A template event listener ran
  • ComponentRef.setInput() was called, or a component was created or destroyed

In other words, simply rewriting a plain property inside setTimeout does not update the screen. The premise is that state is held in Signals.

@Component({
selector: 'app-counter',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<button (click)="inc()">{{ count() }}</button>`,
})
export class CounterComponent {
count = signal(0);

inc(): void {
this.count.update((n) => n + 1); // Updating the Signal triggers re-rendering
}
}

Components built with OnPush and Signals can move to Zoneless almost as they are. For existing apps, the shortcut to migration is to apply OnPush thoroughly first and then replace state with Signals. This is a change whose philosophy gets closer to React's model of "re-render only when state is updated via useState".

4. DestroyRef and takeUntilDestroyed​

DestroyRef is an API that lets you register, from outside the class, work to run at the end of the lifetime of a component, directive, service, and so on (introduced in v16). You no longer need to implement ngOnDestroy, and acquiring and releasing a resource can be written in the same place.

export class ResizeWatcher {
private destroyRef = inject(DestroyRef);

constructor() {
const handler = () => console.log('resized');
window.addEventListener('resize', handler);
this.destroyRef.onDestroy(() => window.removeEventListener('resize', handler));
}
}

Unlike ngOnDestroy, it can also be extracted into helper functions and services. inject(DestroyRef) must be called in an injection context.

Unsubscribing from RxJS​

The takeUntil pattern with a Subject used to be the standard, but it tended to add boilerplate.

// Traditional
private destroy$ = new Subject<void>();

ngOnInit(): void {
this.service.data$.pipe(takeUntil(this.destroy$)).subscribe(/* ... */);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}

With takeUntilDestroyed() from @angular/core/rxjs-interop, one line is enough.

import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

export class OrdersComponent {
private service = inject(OrderService);

constructor() {
// No argument needed inside the constructor (an injection context)
this.service.orders$.pipe(takeUntilDestroyed()).subscribe((o) => this.handle(o));
}
}

When using it outside the constructor (in ngOnInit or methods), pass DestroyRef explicitly.

export class OrdersComponent implements OnInit {
private destroyRef = inject(DestroyRef);

ngOnInit(): void {
this.service.orders$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((o) => this.handle(o));
}
}

Also, toSignal() unsubscribes automatically. If you only need the value in the template, the simplest approach is toSignal(obs$) or the async pipe instead of subscribe.

orders = toSignal(this.service.orders$, { initialValue: [] });

What the cleanup function returned by React's useEffect does is handled in Angular by DestroyRef.onDestroy().

5. afterNextRender / afterRender​

Angular has hooks for safely working with the DOM after rendering completes. Unlike ngAfterViewInit, the important point is that they don't run during SSR (server-side rendering). The server has no window or real DOM layout, so put logic that uses browser APIs here.

APIWhen it runs
afterNextRenderOnce, after the next render completes
afterEveryRenderEvery time a render completes (named afterRender up to v19)

Both run only in the browser and do nothing on the server. Call them in an injection context (constructor or field initializer).

@Component({
selector: 'app-map',
template: `<div #host class="map"></div>`,
})
export class MapComponent {
host = viewChild.required<ElementRef<HTMLElement>>('host');

constructor() {
afterNextRender(() => {
// Runs only in the browser. window and DOM measurement are safe to use
const map = new ThirdPartyMap(this.host().nativeElement);
inject(DestroyRef).onDestroy(() => map.dispose());
});
}
}

Note that inject() can't be used inside the callback (it's outside the injection context), so obtain things like DestroyRef beforehand.

constructor() {
const destroyRef = inject(DestroyRef);
afterNextRender(() => {
const map = new ThirdPartyMap(this.host().nativeElement);
destroyRef.onDestroy(() => map.dispose());
});
}

You can also specify phases to run DOM reads and writes separately. Use this to avoid layout thrashing (forced recalculation from alternating reads and writes).

afterEveryRender({
write: () => {
// DOM write
return this.el.nativeElement.offsetHeight; // A value can be passed to the next phase
},
read: (height) => {
// DOM read
console.log(height);
},
});

Comparison with React useEffect​

afterNextRender plays a role close to useEffect(fn, []) in React, which "runs only on the client after mount". React's useEffect also does not run on the server during SSR.

AspectAngular afterNextRenderReact useEffect
During SSRNot runNot run
UseDOM measurement, browser-only librariesSide effects in general (fetching, subscriptions, DOM operations)
Re-runningOnce only (afterEveryRender for every time)On each dependency change
CleanupRegistered separately with DestroyRefFunction returned

In Angular, instead of consolidating side effects such as data fetching and event subscriptions into useEffect, you choose by purpose among ngOnInit, effect(), DestroyRef, and afterNextRender. As a guideline:

  • Data fetching and initialization: ngOnInit (or resource() / toSignal())
  • Side effects in response to Signal changes: effect()
  • Cleanup on destruction: DestroyRef.onDestroy() / takeUntilDestroyed()
  • Browser-only DOM operations: afterNextRender