Hello! Welcome back.
In our last lesson, we implemented the retry operator, a powerful tool for giving a failing observable stream another chance. This taught us how to manage the stream's lifecycle in response to errors. Today, we'll continue exploring lifecycle management, but this time focusing on successful completions.
This lesson addresses the learning outcome: Implement and test a take operator to limit the number of emissions before automatically completing. The take operator is fundamental in reactive programming for managing infinite or long-lived streams by ensuring they complete after a certain number of values have been emitted.
1. Understanding the take Operator
The take operator is a filtering operator. It allows a specified number of values from a source observable to pass through and then automatically completes the stream. Any further values from the source are ignored, and the source subscription is torn down to prevent resource leaks.
This is incredibly useful in many scenarios, such as:
- Taking only the first value from a configuration stream that might update later.
- Handling user interaction where you only care about the first click.
- Limiting data from a real-time source for display purposes.
A marble diagram is the perfect way to visualize this behavior.

To get a more formal definition and see some practical code examples, the learnrxjs.io documentation is an excellent resource.
Let's review the official documentation for take. This will clarify its signature, purpose, and common use cases.
First, read the introduction to understand the operator's signature and the 'Why use take?' section. Then, review 'Example 1: Take 1 value from source' and 'Example 2: Take the first 5 values from source'. Notice how take stops the stream after the specified number of emissions, even when the source (like interval) is infinite.
To see this in action, let's watch a quick demonstration.
RxJS Operators in Angular 🚀 | Pipe, Map, Tap, Filter Explained with Examples | #2
This clip shows the take operator being applied to an interval observable, which would otherwise run forever.
Watch the segment from 26:01 to 27:28. The presenter demonstrates how an infinite interval stream is cleanly handled and completed by using take(6).
2. Implementing the take Operator
Now, let's implement our own take operator. It will be a pipeable operator that accepts a count argument and returns a new observable.
The core logic resides in the producer function of the new Observable we return. Here's the strategy:
- Keep a counter to track how many values have been emitted.
- When the source emits a value (
next), increment the counter. - If the counter is less than the
takecount, pass the value to the subscriber. - If the counter reaches the
takecount, pass the final value and then immediately callsubscriber.complete(). - If the source completes or errors before the count is reached, pass that notification along to the subscriber.
- Our
Subscriptionarchitecture will ensure that when the stream completes, the subscription to the source is automatically cleaned up.
Here is a skeleton for your take.ts file. Your task is to implement the logic within the next handler.
import { Observable } from "../observable";
import { OperatorFunction } from "../types";
export function take<T>(count: number): OperatorFunction<T, T> {
return (source: Observable<T>): Observable<T> => {
return new Observable((subscriber) => {
// Edge case: if count is 0, the stream should complete immediately.
if (count === 0) {
subscriber.complete();
return;
}
let seen = 0;
const sourceSubscription = source.subscribe({
next(value) {
// TODO: Implement the core take logic.
// 1. Increment the `seen` counter.
// 2. If `seen` is less than `count`, emit the value.
// 3. If `seen` is equal to `count`, emit the value and then complete the stream.
// (Hint: call subscriber.next() then subscriber.complete()).
// 4. If `seen` is greater than `count`, nothing should happen as the stream is already complete.
seen++;
if (seen < count) {
subscriber.next(value);
} else if (seen === count) {
subscriber.next(value);
subscriber.complete();
}
},
error(err) {
// If the source errors, we just pass it along.
subscriber.error(err);
},
complete() {
// If the source completes before we've taken enough values, we complete too.
subscriber.complete();
},
});
// This ensures that if the consumer unsubscribes, we also unsubscribe from the source.
subscriber.add(sourceSubscription);
});
};
}
This implementation correctly handles the main logic. When subscriber.complete() is called, the Subscription's teardown logic is invoked, which in turn unsubscribes from the sourceSubscription, preventing any further processing or memory leaks.
3. Testing Your take Operator
To ensure our take operator is robust, we need to test it against several scenarios. Create a take.test.ts file and cover the following cases:
- Takes N values and completes: The source emits more values than
count. The operator should emit exactlycountvalues and then complete. - Source completes early: The source emits fewer values than
countand then completes. The operator should emit all available values and then complete. take(0): The operator should complete immediately without emitting any values.- Source errors: The source emits an error before
countis reached. The error should be propagated to the subscriber. - Unsubscription: A subscriber unsubscribes before
countis reached. The test should confirm that the source observable is also unsubscribed.
Here is a sample test for the primary success case to get you started:
import { expect, test, vi } from "vitest";
import { of } from "../creation/of";
import { take } from "./take";
import { interval } from "../creation/interval"; // Assuming you have this from a previous lesson
test("should take the first 3 values from a source and complete", () => {
const nextSpy = vi.fn();
const completeSpy = vi.fn();
const errorSpy = vi.fn();
const source$ = of(10, 20, 30, 40, 50);
source$.pipe(take(3)).subscribe({
next: nextSpy,
complete: completeSpy,
error: errorSpy,
});
expect(nextSpy).toHaveBeenCalledTimes(3);
expect(nextSpy).toHaveBeenNthCalledWith(1, 10);
expect(nextSpy).toHaveBeenNthCalledWith(2, 20);
expect(nextSpy).toHaveBeenNthCalledWith(3, 30);
expect(completeSpy).toHaveBeenCalledOnce();
expect(errorSpy).not.toHaveBeenCalled();
});
Conclusion
Excellent work! You have now implemented take, a fundamental operator for controlling the lifecycle of observables.
Key Takeaways:
take(count)emits up tocountvalues from a source observable.- It completes the stream as soon as the
countis reached, preventing further emissions. - The implementation relies on a simple counter within the operator's closure.
- Calling
subscriber.complete()is the key to stopping the stream and triggering resource cleanup.
You've now implemented two powerful operators for managing stream lifecycles: retry for handling errors and take for managing completion.
In our next lesson, we will implement takeUntil. This operator also completes a stream, but instead of using a fixed count, it listens to a second "notifier" observable and completes when that notifier emits its first value. This will introduce you to creating operators that coordinate between multiple streams.
Can't find a good explanation? Sign up and we'll make it for you
Sign up