Key Takeaways
- Angular calls
ngOnDestroy()immediately before destroying a component, directive, pipe, or Angular-managed service instance. - Removing a child with
@ifdestroys it; merely hiding its existing element with CSS does not. - Clean up resources you own, not every observable indiscriminately.
AsyncPipeandtakeUntilDestroyedalready manage subscription teardown. - A service's cleanup follows its owning injector's lifetime, which may differ from the lifetime of the component using it.
Angular ngOnDestroy is the lifecycle hook for cleanup immediately before Angular destroys an instance. Implement it to release instance-owned resources, such as a repeating timer or a manually managed subscription that would otherwise keep running.
The parent-child toggle below makes that destruction visible in the console. Then we'll replace the logging-only hook with actual cleanup and compare it with Angular's lifecycle-aware alternatives.
Understanding ngOnDestroy
Destruction ends an Angular instance's lifecycle. For example, when an @if condition becomes false, Angular destroys the components inside that conditional view. The hook runs once for each destroyed component instance, not on every change-detection pass.
The OnDestroy interface declares ngOnDestroy(): void. Implementing the interface gives you a TypeScript check on the method name and signature; Angular doesn't require the interface to recognize the hook.
For the other end of the lifecycle, see the Angular ngOnInit example. For reacting to changed inputs rather than destruction, see the Angular ngOnChanges example.
Angular ngOnDestroy Example: Toggle a Child Component
Use an existing browser Angular CLI application. Add the two component files below, replace src/main.ts, and replace the root component element in src/index.html with <app-parent></app-parent>. Both components explicitly use standalone configuration; the parent imports the child, and bootstrapApplication starts the parent as the application root.
Child component: log initialization and destruction
// src/app/child.component.ts
import { Component, OnDestroy, OnInit } from '@angular/core';
@Component({
selector: 'app-child',
standalone: true,
template: '<p>This is the child component.</p>',
})
export class ChildComponent implements OnInit, OnDestroy {
ngOnInit(): void {
console.log('child init');
}
ngOnDestroy(): void {
console.log('destroying child...');
}
}Parent component: conditionally render the child
// src/app/parent.component.ts
import { Component, OnInit } from '@angular/core';
import { ChildComponent } from './child.component';
@Component({
selector: 'app-parent',
standalone: true,
imports: [ChildComponent],
template: `
<button type="button" (click)="update()">Toggle Child</button>
@if (showChild) {
<app-child />
}
`,
})
export class ParentComponent implements OnInit {
showChild = true;
update(): void {
this.showChild = !this.showChild;
}
ngOnInit(): void {
console.log('parent init');
}
}Bootstrap the example
// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { ParentComponent } from './app/parent.component';
bootstrapApplication(ParentComponent).catch((error: unknown) => {
console.error('Angular bootstrap failed:', error);
});Observe what each click does
- Initially, the parent and child initialize, logging
parent initandchild init. - The first click sets
showChildto false. Angular destroys the child, loggingdestroying child.... - The next click creates a new child instance, logging
child initagain. It doesn't resurrect the previous instance.
The parent stays alive throughout these toggles. Only the true-to-false transition destroys the child; the false-to-true transition creates it.
Click Toggle Child
|
v
showChild becomes false
|
v
Angular destroys the conditional child view
|
+--> ChildComponent.ngOnDestroy()
|
v
The old child's lifecycle endsWhen Should You Use ngOnDestroy?
Ask two questions: Who owns this resource, and what ends its lifetime? A long-lived subscription can retain a component through its callback, while a completed subscription no longer represents ongoing work. Cleanup matters when something can remain active or retain references beyond its intended lifetime.
| Resource or pattern | Cleanup approach |
|---|---|
Observable rendered with AsyncPipe | Let the pipe unsubscribe when its view is destroyed. |
| Manually subscribed, long-lived observable | Use takeUntilDestroyed, or own the subscription and unsubscribe explicitly. |
| Observable that completes | No later unsubscribe is needed for completed work; consider earlier cancellation if its owner disappears first. |
| Repeating timer | Keep its timer ID and call clearInterval. |
| Directly registered DOM listener | Remove it with the matching listener reference and capture setting. |
| Angular template event binding | Prefer Angular's binding rather than creating an additional manual listener. |
These mechanisms cover different ownership models: AsyncPipe owns its subscription, RxJS subscriptions expose explicit disposal, and browser timers and listeners have their own teardown APIs. Angular's template event system also supports listener cleanup.
HttpClient observables usually complete after receiving a response, although interceptors can change that behavior. Unsubscribing before completion aborts an in-progress request and avoids callbacks running against a destroyed component; it isn't a guarantee that the server undoes an operation it has already received.
Concrete cleanup: stop a repeating timer
Replace child.component.ts with this browser-demo variant. The interval captures the child through this, so leaving it running would keep doing work and retain that reference. clearInterval cancels the repeating action.
// src/app/child.component.ts
import { Component, OnDestroy } from '@angular/core';
@Component({
selector: 'app-child',
standalone: true,
template: '<p>Open the console to watch the timer.</p>',
})
export class ChildComponent implements OnDestroy {
private ticks = 0;
private readonly timerId = setInterval(() => {
console.log('child tick', ++this.ticks);
}, 1000);
ngOnDestroy(): void {
clearInterval(this.timerId);
console.log('destroying child...');
}
}Toggle the child off: its timer stops. Toggle it back on: a new instance starts a new timer. This is resource cleanup, rather than a hook that only logs.
DestroyRef and takeUntilDestroyed
DestroyRef lets you register destruction callbacks with onDestroy(), keeping setup and teardown near each other. Inject it in a component for component-scoped cleanup; in other contexts, its lifetime follows the corresponding injector.
For RxJS subscriptions, takeUntilDestroyed from @angular/core/rxjs-interop completes the subscription's observable chain when the supplied destruction scope ends. Here's another replacement child component, this time using an RxJS interval.
// src/app/child.component.ts
import { Component, DestroyRef, inject, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({
selector: 'app-child',
standalone: true,
template: '<p>This child owns an RxJS subscription.</p>',
})
export class ChildComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
constructor() {
this.destroyRef.onDestroy(() => {
console.log('destroying child...');
});
}
ngOnInit(): void {
interval(1000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((tick) => {
console.log('child tick', tick);
});
}
}An injection context is execution where Angular makes its current injector available, including an Angular-created class's constructor and field initializers. You can omit the argument to takeUntilDestroyed() there. An ordinary ngOnInit call isn't an injection context, so this example captures DestroyRef during construction and passes it explicitly.
If the observable only supplies template values, prefer AsyncPipe: import AsyncPipe from @angular/common, add it to the component's imports, and bind with an expression such as {{ ticks$ | async }}. The pipe handles its own unsubscription.
Services: Cleanup Follows the Provider's Scope
Services can implement OnDestroy. The earlier claim that this hook is limited to components and directives was incorrect; Angular also supports it for services and pipes.
- A service provided with
providedIn: 'root'belongs to the root environment injector; destroying one consuming child component doesn't destroy that shared service. - A service provided in a component's
providersbelongs to that component's injector and is destroyed with the component.
Angular's hierarchical injector guide explains these scopes. Consequently, a DestroyRef injected into a root service doesn't become component-scoped just because a component calls that service.
Keep component-specific subscriptions in the component when their lifetime should end with it. Don't shut down a shared service's resources merely because one consumer disappears.
Test That Destruction Runs Cleanup
Destroy the Angular fixture instead of calling a lifecycle method directly. ComponentFixture.destroy() triggers component destruction, exercising Angular's teardown path. The following cleanup.spec.ts runs in an Angular project configured with its Vitest testing environment.
// src/app/cleanup.spec.ts
import { Component, DestroyRef, inject } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { afterEach, expect, it } from 'vitest';
@Component({
selector: 'app-cleanup-probe',
standalone: true,
template: '',
})
class CleanupProbe {
cleanedUp = false;
constructor() {
inject(DestroyRef).onDestroy(() => {
this.cleanedUp = true;
});
}
}
afterEach(() => {
TestBed.resetTestingModule();
});
it('runs the registered cleanup when the fixture is destroyed', async () => {
await TestBed.configureTestingModule({
imports: [CleanupProbe],
}).compileComponents();
const fixture = TestBed.createComponent(CleanupProbe);
const component = fixture.componentInstance;
expect(component.cleanedUp).toBe(false);
fixture.destroy();
expect(component.cleanedUp).toBe(true);
});For a real timer, listener, or subscription, also assert the resource-specific outcome: no more timer work, removal of the listener, or execution of the subscription's teardown. A log message alone doesn't prove that the resource was released.
Common Mistakes
- Confusing hiding with destruction. CSS such as
display: nonechanges presentation without destroying the Angular instance. Its timers and subscriptions remain active unless separately stopped. - Calling ngOnDestroy manually to remove a component. Calling the method only executes your method body; use Angular's destruction APIs or remove the conditional view to trigger actual lifecycle teardown.
- Calling takeUntilDestroyed() without a context. In lifecycle hooks and event handlers, pass a previously injected
DestroyRef. - Treating unsubscription as server-side rollback. Stop component-owned work, but design server-side cancellation or rollback explicitly when the operation requires it.
FAQ
When is ngOnDestroy called in Angular?
Angular calls ngOnDestroy once, immediately before destroying an instance. Removing a child through an @if condition triggers it; rendering that child again creates a new instance.
Do Angular services support ngOnDestroy?
Yes. Angular-managed services can implement OnDestroy, and their cleanup follows the lifetime of their owning injector. A component-provided service is destroyed with its component, while a root-provided service isn't destroyed when one consuming component disappears.
Do I need to unsubscribe from every observable in ngOnDestroy?
No. AsyncPipe manages its own subscriptions, and subscriptions that have already completed need no further unsubscribe. Use lifecycle-aware teardown or explicit unsubscription for manually managed work that could outlive its owner.
How do DestroyRef and takeUntilDestroyed work?
DestroyRef registers callbacks for the destruction of a component or injector scope. takeUntilDestroyed uses that scope to end an observable chain; pass a DestroyRef explicitly when calling it outside an injection context.
Does hiding a component with CSS trigger ngOnDestroy?
No. CSS hiding leaves the Angular component instance alive. Removing it through @if, or through existing *ngIf code, destroys the conditional child view and triggers its destruction hook.
Further Reading
- Angular component lifecycle — destruction timing and lifecycle interfaces.
- OnDestroy API reference — the hook's signature and supported instance types.
- NgIf API reference — deprecation guidance and the recommended replacement.
- DestroyRef API reference — callback registration and destruction scope.
- Unsubscribing with takeUntilDestroyed — injection-context rules and explicit DestroyRef usage.
- AsyncPipe API reference — automatic subscription management.
- Angular HTTP requests — observable completion and request cancellation.
- ComponentFixture API reference — triggering destruction in tests.
