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
| Order | Hook | Timing | Frequency |
|---|---|---|---|
| 1 | constructor | On instance creation. DI injection and field initialization | Once |
| 2 | ngOnChanges | When an input property changes (before ngOnInit the first time) | On every change |
| 3 | ngOnInit | Initialization after input initial values are set | Once |
| 4 | ngDoCheck | On every change detection (for custom detection logic) | Every cycle |
| 5 | ngAfterContentInit | After projected content (ng-content) is initialized | Once |
| 6 | ngAfterContentChecked | After projected content is checked | Every cycle |
| 7 | ngAfterViewInit | After the component's own view and child views are initialized | Once |
| 8 | ngAfterViewChecked | After the view is checked | Every cycle |
| 9 | ngOnDestroy | Just before destruction | Once |
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
| Angular | React (class) | React (function) |
|---|---|---|
ngOnInit | componentDidMount | useEffect(fn, []) |
ngOnChanges | componentDidUpdate | useEffect with a dependency array |
ngOnDestroy | componentWillUnmount | Cleanup function returned by useEffect |
ngAfterViewInit | componentDidMount (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
asyncpipe 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
});
}
}
| Method | Behavior |
|---|---|
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.
| Aspect | React | Angular |
|---|---|---|
| What is re-run | The entire component function | Only the template update instructions |
| Unit of comparison | Virtual DOM tree | Template expression values |
| Optimization tools | memo / useMemo / useCallback | OnPush / Signals |
| Update trigger | Calling setState | Zone.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.
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
asyncpipe 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.
| API | When it runs |
|---|---|
afterNextRender | Once, after the next render completes |
afterEveryRender | Every 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.
| Aspect | Angular afterNextRender | React useEffect |
|---|---|---|
| During SSR | Not run | Not run |
| Use | DOM measurement, browser-only libraries | Side effects in general (fetching, subscriptions, DOM operations) |
| Re-running | Once only (afterEveryRender for every time) | On each dependency change |
| Cleanup | Registered separately with DestroyRef | Function 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(orresource()/toSignal()) - Side effects in response to Signal changes:
effect() - Cleanup on destruction:
DestroyRef.onDestroy()/takeUntilDestroyed() - Browser-only DOM operations:
afterNextRender