Create your own
Lesson illustration

Implementing and Testing the `take` Operator

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.

This diagram shows a source observable emitting values `a`, `b`, `c`, and `d`. The `take(2)` operator allows `a` and `b` to pass through to the output stream and then immediately issues a completion signal (the vertical bar). The values `c` and `d` are never emitted on the output stream.

To get a more formal definition and see some practical code examples, the learnrxjs.io documentation is an excellent resource.

take

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:

  1. Keep a counter to track how many values have been emitted.
  2. When the source emits a value (next), increment the counter.
  3. If the counter is less than the take count, pass the value to the subscriber.
  4. If the counter reaches the take count, pass the final value and then immediately call subscriber.complete().
  5. If the source completes or errors before the count is reached, pass that notification along to the subscriber.
  6. Our Subscription architecture 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:

  1. Takes N values and completes: The source emits more values than count. The operator should emit exactly count values and then complete.
  2. Source completes early: The source emits fewer values than count and then completes. The operator should emit all available values and then complete.
  3. take(0): The operator should complete immediately without emitting any values.
  4. Source errors: The source emits an error before count is reached. The error should be propagated to the subscriber.
  5. Unsubscription: A subscriber unsubscribes before count is 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 to count values from a source observable.
  • It completes the stream as soon as the count is 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