Hello! Let's dive into our next lesson.
In our previous session, we implemented the take operator, which completes a stream after a fixed number of emissions. This was our first step in programmatically managing the lifecycle of an observable. Today, we'll build on that by exploring a more dynamic way to complete a stream.
This lesson addresses the learning outcome: Implement and test a takeUntil operator that completes the source stream based on an emission from a notifier observable. This operator is a cornerstone of reactive programming, especially for managing subscriptions tied to UI component lifecycles or other asynchronous events. It introduces the important concept of coordinating between two separate observable streams.
1. Understanding the takeUntil Operator
The takeUntil operator mirrors a source observable, passing its values along. However, it also subscribes to a second observable, called the "notifier." The moment the notifier emits its first value, takeUntil completes the main stream. Any subsequent emissions from the notifier are ignored.
This behavior is perfectly captured by a marble diagram.

To get a formal definition and see its signature, let's consult the official RxJS documentation.
The RxJS documentation provides a concise definition, a clear marble diagram, and the operator's signature. This is the best place to start for a formal understanding.
Please read the main definition, the 'Description' section, and the 'Example'. Pay close attention to how the notifier observable (in the example, clicks) dictates when the source observable (interval) is completed and unsubscribed from.
Now, let's see a practical demonstration of this concept. The most common use case involves a long-lived stream (like an interval or a WebSocket connection) and a Subject that acts as the notifier.
TakeUntil Operator in Rxjs | Effective use of TakeUntil Operator in Angular
This video provides a clear, simple demonstration of takeUntil in action. It uses an interval observable as the source and a Subject as the notifier, which is a very common pattern.
Watch the segment from 01:04 to 04:08. The presenter sets up an interval and uses a setTimeout to trigger a Subject's next() method. Observe how the interval stream stops exactly when the subject emits.
The pattern you just saw is fundamental for preventing memory leaks in component-based frameworks like Angular, React, or Vue, where you might trigger the notifier subject when a component is unmounted.
2. Implementing the takeUntil Operator
Now it's time to build our own takeUntil. This operator is more complex than take because it involves managing two subscriptions simultaneously: one to the source and one to the notifier.
Here's the implementation strategy:
- The
takeUntilfunction accepts anotifierobservable and returns a pipeable operator. - The returned operator creates and returns a new
Observable. - Inside the producer function of this new observable, we subscribe to the
source. Anynext,error, orcompletenotifications from the source are passed directly to our mainsubscriber. - Crucially, we also subscribe to the
notifierobservable. - When the
notifieremits its firstnextvalue, we immediately callsubscriber.complete(). We don't need to listen for any more values from the notifier. - Resource Management: Both the source and notifier subscriptions must be cleaned up properly. The
Subscriptionobject we built is perfect for this. We will add both internal subscriptions to the main subscriber's subscription. When the stream terminates (either by completion, error, or unsubscription), ourSubscriptionteardown logic will automatically unsubscribe from both the source and the notifier.
Here is the skeleton for your takeUntil.ts file. Your task is to set up and manage the two subscriptions.
import { Observable } from "../observable";
import { OperatorFunction } from "../types";
export function takeUntil<T>(notifier: Observable<any>): OperatorFunction<T, T> {
return (source: Observable<T>): Observable<T> => {
return new Observable((subscriber) => {
// Subscribe to the source observable.
// Pass through next, error, and complete events.
const sourceSubscription = source.subscribe({
next(value) {
subscriber.next(value);
},
error(err) {
subscriber.error(err);
},
complete() {
subscriber.complete();
},
});
// Add the source subscription to the main subscription for cleanup.
subscriber.add(sourceSubscription);
// TODO: Implement the notifier logic.
// 1. Subscribe to the `notifier` observable.
// 2. In the `next` handler of the notifier's subscription, call `subscriber.complete()`.
// This will trigger the teardown of the whole chain.
// 3. You don't need to handle the notifier's `error` or `complete` cases,
// as they don't cause the source to complete.
// 4. Add the notifier subscription to the main subscriber for proper cleanup.
const notifierSubscription = notifier.subscribe({
next() {
subscriber.complete();
},
// No need to handle error or complete from the notifier
});
subscriber.add(notifierSubscription);
});
};
}
This structure ensures that no matter how the stream terminates—the notifier fires, the source completes, the source errors, or the consumer unsubscribes—all internal subscriptions are properly disposed of.
3. Testing Your takeUntil Operator
A robust test suite is essential. For takeUntil, we need to verify its behavior in several key scenarios.
Create a takeUntil.test.ts file and add tests for the following cases:
- Notifier emits before source completes: The source should stop emitting values and complete as soon as the notifier emits.
- Source completes before notifier emits: The output stream should complete normally when the source completes.
- Source errors before notifier emits: The error should be propagated to the subscriber.
- Notifier completes without emitting: The source stream should be unaffected and continue emitting values.
- Unsubscription: If the consumer unsubscribes, ensure both the source and notifier subscriptions are torn down. You can use
vi.spyOnon theunsubscribemethods of mock observables to confirm this.
Here’s a sample test for the primary success case to get you started. You will need an implementation of Subject from a previous lesson to write this test.
import { expect, test, vi } from "vitest";
import { interval } from "../creation/interval"; // Assuming you have this
import { Subject } from "../subject"; // Assuming you have this
import { takeUntil } from "./takeUntil";
import { take } from "./take"; // Useful for making the test deterministic
test("should emit values until the notifier emits", () => {
const nextSpy = vi.fn();
const completeSpy = vi.fn();
const errorSpy = vi.fn();
const source$ = interval(10); // Emits 0, 1, 2, ... every 10ms
const notifier$ = new Subject<void>();
// We use take(5) to ensure the test terminates even if takeUntil fails.
source$.pipe(takeUntil(notifier$), take(5)).subscribe({
next: nextSpy,
complete: completeSpy,
error: errorSpy,
});
// Let some values pass through
setTimeout(() => {
// At 25ms, source should have emitted 0 and 1.
expect(nextSpy).toHaveBeenCalledWith(0);
expect(nextSpy).toHaveBeenCalledWith(1);
// Now, trigger the notifier
notifier$.next();
}, 25);
// Wait for the stream to complete
return new Promise((resolve) => {
setTimeout(() => {
expect(nextSpy).toHaveBeenCalledTimes(2); // Only 0 and 1 should have been emitted
expect(completeSpy).toHaveBeenCalledOnce();
expect(errorSpy).not.toHaveBeenCalled();
resolve();
}, 50);
});
});
Note: This test uses setTimeout for simplicity. In a more advanced setup, you would use fake timers (vi.useFakeTimers()) for more reliable and faster asynchronous tests.
4. An Important Pitfall: Operator Order
Your experience as a lead developer means you know that subtle implementation details can lead to major bugs. With takeUntil, there is a classic pitfall related to operator ordering that causes memory leaks.
It is crucial to place takeUntil at or near the end of your pipe chain.
Pitfalls Of Using takeUntil and takeUntilDestroyed RxJS Operators
This video explains one of the most common and dangerous mistakes when using takeUntil. Understanding this is vital for writing correct, leak-free reactive code.
Watch the segments from 00:26-02:18 and 03:18-04:32. The presenter demonstrates why placing takeUntil before an operator that creates an inner subscription (like switchMap) causes a memory leak, and explains the general rule of placing it at the end of the chain.
The reason for this is that when takeUntil completes the stream, it triggers the teardown logic for all operators that came before it in the chain. If an operator like mergeMap or switchMap comes after takeUntil, its inner subscription will never be told to unsubscribe, creating a memory leak.
Conclusion
Congratulations! You've implemented takeUntil, a sophisticated and highly practical operator.
Key Takeaways:
takeUntilcompletes a source observable when a secondnotifierobservable emits a value.- Its implementation requires managing two subscriptions concurrently, making robust resource management (via our
Subscriptionclass) essential. - The operator is a primary tool for preventing memory leaks by tying an observable's lifetime to another event, such as a component's destruction.
- The placement of
takeUntilin an operator chain is critical; it should almost always be the last operator to ensure all other operators in the chain are properly torn down.
This lesson concludes our module on core pipeable operators for error handling and completion. You now have a solid set of tools for managing the observable lifecycle.
In our next lesson, we will begin a new module and shift our focus from building individual operators to creating a higher-level abstraction. We will start designing a type-safe Event Bus, which will leverage the observable foundation you've built to provide a powerful application-level communication pattern.
Can't find a good explanation? Sign up and we'll make it for you
Sign up