Component Lifecycle Hooks — OnInit, OnChanges, OnDestroy, and the Full Sequence

Every Angular component goes through a well-defined lifecycle — from creation through rendering through destruction. Angular provides lifecycle hooks: methods on the component class that Angular calls automatically at specific points in this lifecycle. Knowing which hook to use for which task — initialising data in ngOnInit, responding to input changes in ngOnChanges, cleaning up subscriptions in ngOnDestroy — is fundamental to building correct, leak-free Angular applications. Using the wrong hook (or no hook at all) for initialisation and cleanup is one of the most common sources of bugs in Angular apps.

Lifecycle Hook Sequence

Order Hook Called this Receives
1 constructor() When Angular creates the component instance Injected dependencies available
2 ngOnChanges(changes) Before ngOnInit and whenever an @Input changes SimpleChanges object with previous and current values
3 ngOnInit() Once after first ngOnChanges — inputs are set All @Input values available
4 ngDoCheck() Every change detection cycle (use sparingly) Custom change detection
5 ngAfterContentInit() Once after projected content (ng-content) is initialised Content children available
6 ngAfterContentChecked() After every check of projected content Content children checked
7 ngAfterViewInit() Once after component’s view and children are initialised @ViewChild references available
8 ngAfterViewChecked() After every check of the view and children View fully updated
9 ngOnDestroy() Just before Angular destroys the component Last chance for cleanup

Most-Used Hooks and Their Purpose

Hook Primary Uses Frequency
ngOnInit() Load initial data, subscribe to route params, set up state Once per component creation
ngOnChanges() React to @Input changes — refresh data when parent passes new ID Before init + every input change
ngOnDestroy() Unsubscribe from Observables, clear timers, remove event listeners Once before component removed from DOM
ngAfterViewInit() Access @ViewChild elements — initialise third-party DOM libraries Once after view is rendered
ngAfterContentInit() Access @ContentChild projected content from the parent Once after content projected
Note: The constructor receives dependency-injected services but should not perform async operations or access @Input values — inputs are not set when the constructor runs. Use ngOnInit() for initialisation logic. The constructor is for receiving injected dependencies and setting up initial synchronous state only. Think of the constructor as Angular’s way to wire up dependencies, and ngOnInit as the “start up” method that runs once the component is fully configured.
Tip: The modern pattern for cleanup in Angular 16+ uses takeUntilDestroyed() from @angular/core/rxjs-interop instead of a manual Subject + ngOnDestroy. Import takeUntilDestroyed and chain it on any Observable subscription: this.taskService.getAll().pipe(takeUntilDestroyed()).subscribe(...). It automatically unsubscribes when the component is destroyed — no manual cleanup code needed. The DestroyRef injection token is the underlying mechanism and can also be used for non-RxJS cleanup.
Warning: Performing DOM manipulation in ngAfterViewInit() and then triggering Angular change detection (by modifying bound properties) will cause ExpressionChangedAfterItHasBeenCheckedError in development mode. This happens because Angular has already recorded the expression values for this cycle. The fix is to wrap the state change in setTimeout(() => { ... }) or use ChangeDetectorRef.detectChanges() to explicitly trigger a new detection cycle. This error only appears in development mode — it is Angular’s way of warning you about a real problem.

Complete Lifecycle Hook Examples

// task-detail.component.ts — demonstrates all major lifecycle hooks
import {
    Component, Input, OnInit, OnChanges, OnDestroy,
    AfterViewInit, SimpleChanges, ViewChild, ElementRef,
    inject, signal,
} from '@angular/core';
import { Subject, Subscription }     from 'rxjs';
import { takeUntil, switchMap }      from 'rxjs/operators';
import { takeUntilDestroyed }        from '@angular/core/rxjs-interop';
import { ActivatedRoute }            from '@angular/router';
import { TaskService }               from '../../../core/services/task.service';
import { Task }                      from '../../../shared/models/task.model';

@Component({
    selector:   'app-task-detail',
    standalone: true,
    templateUrl:'./task-detail.component.html',
})
export class TaskDetailComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit {

    // ── Inputs from parent component ──────────────────────────────────────
    @Input() taskId?: string;   // can be passed from parent OR from route

    // ── ViewChild — DOM element ref (available after ngAfterViewInit) ──────
    @ViewChild('titleInput') titleInput?: ElementRef<HTMLInputElement>;

    // ── Injected dependencies ─────────────────────────────────────────────
    private route       = inject(ActivatedRoute);
    private taskService = inject(TaskService);

    // ── Component state ───────────────────────────────────────────────────
    task    = signal<Task | null>(null);
    loading = signal(false);
    error   = signal<string | null>(null);

    // ── For manual unsubscribe pattern (legacy — prefer takeUntilDestroyed) ─
    private destroy$ = new Subject<void>();

    // ── CONSTRUCTOR: inject only, no logic ────────────────────────────────
    constructor() {
        // Nothing here — just DI via inject() or constructor params
        // @Input() values are NOT available yet
    }

    // ── ngOnChanges: fires before ngOnInit and on every @Input change ────
    ngOnChanges(changes: SimpleChanges): void {
        if (changes['taskId']) {
            const { previousValue, currentValue, firstChange } = changes['taskId'];
            console.log(`taskId changed: ${previousValue} -> ${currentValue}`);

            if (!firstChange && currentValue) {
                // @Input changed after init — reload data
                this.loadTask(currentValue);
            }
        }
    }

    // ── ngOnInit: safe to access @Input values and subscribe ─────────────
    ngOnInit(): void {
        // Option 1: Load task from @Input prop
        if (this.taskId) {
            this.loadTask(this.taskId);
        }

        // Option 2: Load task from route parameter
        this.route.params.pipe(
            switchMap(params => {
                this.loading.set(true);
                return this.taskService.getById(params['id']);
            }),
            takeUntilDestroyed(),   // auto-unsubscribes on destroy (Angular 16+)
        ).subscribe({
            next:  task  => { this.task.set(task); this.loading.set(false); },
            error: err   => { this.error.set(err.message); this.loading.set(false); },
        });

        // Manual subscription with legacy destroy$ pattern
        this.taskService.getTaskUpdates().pipe(
            takeUntil(this.destroy$)           // unsubscribes when destroy$ emits
        ).subscribe(update => {
            if (update.taskId === this.task()?._id) {
                this.task.set({ ...this.task()!, ...update.changes });
            }
        });
    }

    // ── ngAfterViewInit: @ViewChild references are now available ─────────
    ngAfterViewInit(): void {
        // Auto-focus the title input when the form loads
        if (this.titleInput) {
            // Use setTimeout to avoid ExpressionChangedAfterItHasBeenCheckedError
            setTimeout(() => this.titleInput?.nativeElement.focus(), 0);
        }

        // Initialise third-party DOM library (e.g. a code editor, chart)
        // this.editor = new ExternalEditor(this.editorContainer.nativeElement);
    }

    // ── ngOnDestroy: cleanup — ALWAYS clean up subscriptions and resources ─
    ngOnDestroy(): void {
        // Complete the destroy$ subject — all takeUntil(this.destroy$) unsubscribe
        this.destroy$.next();
        this.destroy$.complete();

        // Clean up any external resources
        // this.editor?.destroy();
        // clearInterval(this.pollingInterval);
        // window.removeEventListener('resize', this.resizeHandler);
    }

    private loadTask(id: string): void {
        this.loading.set(true);
        this.taskService.getById(id).subscribe({
            next:  task => { this.task.set(task); this.loading.set(false); },
            error: err  => { this.error.set(err.message); this.loading.set(false); },
        });
    }
}

// ── Modern cleanup with takeUntilDestroyed (preferred Angular 16+) ─────────
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

@Component({ standalone: true, ... })
export class ModernComponent implements OnInit {
    private taskService = inject(TaskService);
    tasks = signal<Task[]>([]);

    ngOnInit(): void {
        // No Subject, no ngOnDestroy needed — takeUntilDestroyed handles it
        this.taskService.getAll()
            .pipe(takeUntilDestroyed())
            .subscribe(tasks => this.tasks.set(tasks));
    }
    // No ngOnDestroy needed at all!
}

How It Works

Step 1 — ngOnInit Is the Safe Initialisation Point

The constructor runs before Angular has set @Input() values or resolved the component’s full DI context. ngOnInit() runs after the first ngOnChanges(), meaning all @Input() values are available. This is the correct place to make HTTP calls, subscribe to Observables, access route parameters, and set up reactive state that depends on inputs. Doing these in the constructor leads to timing bugs and null reference errors.

Step 2 — ngOnChanges Detects Input Property Changes

Every time a parent component updates a bound @Input() property, Angular calls ngOnChanges() with a SimpleChanges object. Each changed input has a key in the object with previousValue, currentValue, and firstChange. This is the correct hook to reload data when the parent passes a new task ID — check !changes['taskId'].firstChange to avoid double-loading on init (since ngOnChanges fires once before ngOnInit).

Step 3 — ngOnDestroy Prevents Memory Leaks

When Angular removes a component from the DOM — due to navigation, *ngIf becoming false, or the application closing — it calls ngOnDestroy(). This is the only guaranteed cleanup opportunity. Any Observable subscriptions that are not unsubscribed, timers that are not cleared, and DOM event listeners that are not removed continue running after the component is destroyed — this is a memory leak. Always clean up in ngOnDestroy() or use takeUntilDestroyed().

Step 4 — ngAfterViewInit Provides Access to ViewChild References

@ViewChild('myRef') myRef: ElementRef references a template element by its template reference variable. These references are set after Angular renders the view — which is after ngOnInit() runs. Accessing this.myRef in ngOnInit() returns undefined. Use ngAfterViewInit() for operations that require DOM access: focusing an input, measuring element dimensions, initialising a third-party chart or editor library.

Step 5 — takeUntilDestroyed Is the Modern Cleanup Pattern

Angular 16 introduced takeUntilDestroyed() from @angular/core/rxjs-interop. When piped onto an Observable, it subscribes to a DestroyRef signal that fires when the component is destroyed. No Subject, no ngOnDestroy, no boilerplate. You can also inject DestroyRef directly for non-RxJS cleanup: inject(DestroyRef).onDestroy(() => cleanup()).

Real-World Example: Route-Driven Component with Full Lifecycle

// task-list.component.ts — route-driven with proper lifecycle management
import { Component, OnInit, signal, computed, inject } from '@angular/core';
import { ActivatedRoute, Router }  from '@angular/router';
import { takeUntilDestroyed }      from '@angular/core/rxjs-interop';
import { TaskService }             from '../../../core/services/task.service';
import { Task }                    from '../../../shared/models/task.model';

@Component({ ... })
export class TaskListComponent implements OnInit {
    private taskService = inject(TaskService);
    private route       = inject(ActivatedRoute);
    private router      = inject(Router);

    tasks       = signal<Task[]>([]);
    filterStatus= signal('');
    loading     = signal(true);
    error       = signal<string | null>(null);

    // Computed signals — recalculated only when dependencies change
    filteredTasks = computed(() => {
        const status = this.filterStatus();
        return status ? this.tasks().filter(t => t.status === status) : this.tasks();
    });

    pendingCount  = computed(() => this.tasks().filter(t => t.status === 'pending').length);

    ngOnInit(): void {
        // Subscribe to query params for filter state
        this.route.queryParams.pipe(
            takeUntilDestroyed()   // auto-cleanup on destroy
        ).subscribe(params => {
            this.filterStatus.set(params['status'] ?? '');
        });

        // Load tasks
        this.taskService.getAll().pipe(
            takeUntilDestroyed()
        ).subscribe({
            next:  tasks => { this.tasks.set(tasks); this.loading.set(false); },
            error: err   => { this.error.set(err.message); this.loading.set(false); },
        });
    }

    setFilter(status: string): void {
        this.router.navigate([], {
            queryParams: { status: status || null },
            queryParamsHandling: 'merge',
        });
    }

    trackById = (_: number, task: Task): string => task._id;
}

Common Mistakes

Mistake 1 — Initialising data in the constructor

❌ Wrong — @Input values not available in constructor:

constructor(private taskService: TaskService) {
    this.taskService.getById(this.taskId).subscribe(...);
    // this.taskId is undefined! @Input not set yet
}

✅ Correct — use ngOnInit where @Input values are available:

ngOnInit(): void {
    this.taskService.getById(this.taskId!).subscribe(...);
    // this.taskId is now set by Angular
}

Mistake 2 — Not unsubscribing — memory leak

❌ Wrong — subscription lives forever after component destroyed:

ngOnInit(): void {
    this.taskService.getLiveUpdates().subscribe(update => {
        this.tasks.set(update);  // keeps running after navigation away!
    });
}

✅ Correct — use takeUntilDestroyed for automatic cleanup:

ngOnInit(): void {
    this.taskService.getLiveUpdates()
        .pipe(takeUntilDestroyed())
        .subscribe(update => this.tasks.set(update));
}

Mistake 3 — Accessing @ViewChild in ngOnInit

❌ Wrong — view not rendered yet, reference is undefined:

@ViewChild('myInput') myInput!: ElementRef;

ngOnInit(): void {
    this.myInput.nativeElement.focus();  // TypeError: Cannot read property 'nativeElement' of undefined
}

✅ Correct — access ViewChild in ngAfterViewInit:

ngAfterViewInit(): void {
    setTimeout(() => this.myInput?.nativeElement.focus(), 0);
}

Quick Reference

Hook When Use For
constructor() Component created Receive injected services only
ngOnChanges() Before init + each @Input change React to input prop changes
ngOnInit() Once after first ngOnChanges Load data, subscribe to Observables/routes
ngAfterViewInit() Once after view rendered Access @ViewChild, init DOM libraries
ngOnDestroy() Before component removed Unsubscribe, clear timers, remove listeners
takeUntilDestroyed() Operator in pipe Modern auto-cleanup (Angular 16+)
inject(DestroyRef).onDestroy() Registration Non-RxJS cleanup without ngOnDestroy

🧠 Test Yourself

A component subscribes to a WebSocket service in ngOnInit() without unsubscribing. The user navigates away. What happens?