When working with Angular and RxJS, takeUntil
is a common operator for unsubscribing from observables, especially in component lifecycle management. However, if you're using takeUntil incorrectly with higher-order mapping operators like mergeMap
, switchMap
, concatMap
, exhaustMap
, you may still end up with memory leaks.
The Problem
A common mistake is placing takeUntil
before a higher-order operator in your observable pipeline. This seems logical, but it doesn't always behave as expected. When takeUntil
is upstream of a higher-order operator, it won't effectively unsubscribe the inner subscription created by that operator.
β Problematic Pattern
this.counterB$.pipe(
takeUntil(this.destroySubject),
mergeMap((value) => this.counterA$)
).subscribe(...);
In the example above, the outer observable (counterB$
) is unsubscribed, but the inner observable (counterA$
from mergeMap
) continues to emit values even after the component is destroyed. Thatβs your memory leak!
Why It Happens
Higher-order operators return new inner observables. If takeUntil
is applied before them, it only stops the outer observable. But the inner one lives on.
The Correct Pattern
To ensure both outer and inner subscriptions are cleaned up properly, place takeUntil
after the higher-order operator.
β Recommended Pattern
this.counterA$.pipe(
mergeMap((value) => this.counterB$),
takeUntil(this.destroySubject),
).subscribe(...);
In this corrected example, the whole observable chain, including inner subscriptions, is properly unsubscribed when the destroySubject
emits.
Real-World Example
Service Providing Observables
@Injectable({ providedIn: 'root' })
export class TakeUntilLeakChildService {
counterA$ = interval(2000);
counterB$ = interval(1000);
}
Component Subscription
@Component({
selector: 'app-take-until-leak-child',
imports: [],
templateUrl: './take-until-leak-child.component.html',
styleUrl: './take-until-leak-child.component.scss'
})
export class TakeUntilLeakChildComponent implements OnInit, OnDestroy {
counterService = inject(TakeUntilLeakChildService);
counterA$ = this.counterService.counterA$;
counterB$ = this.counterService.counterB$;
destroySubject = new Subject();
ngOnInit(): void {
this.counterA$.pipe(
mergeMap((value) => {
return this.counterB$;
}),
takeUntil(this.destroySubject),
).subscribe((value) => {
console.log('Counter A:', value);
});
this.counterB$.pipe(
takeUntil(this.destroySubject),
mergeMap((value) => {
return this.counterA$;
}),
).subscribe((value) => {
console.log('Counter B:', value);
});
}
ngOnDestroy(): void {
console.log('Child component destroyed!');
this.destroySubject.next(true);
this.destroySubject.complete();
}
}
Console Observation
When inspecting the console, you may find that:
-
counterA$
is unsubscribed correctly. -
counterB$
continues to emit values even after component destruction - memory leak.
Related Note on takeUntilDestroyed
The same principle applies when using Angular's takeUntilDestroyed
operator. If you're using takeUntilDestroyed
with higher-order mapping operators, make sure it is placed after those operators to properly unsubscribe inner observables and avoid memory leaks.
Summary
To prevent memory leaks:
-
Always place
takeUntil
(ortakeUntilDestroyed
) after higher-order operators. - Understand that
mergeMap
,switchMap
,concatMap
, andexhaustMap
create inner observables that need cleanup.
Happy coding!
Top comments (0)