RxJS: Getting fooled by empty Observables

Hallstein Brøtan

Empty observables can be dangerous when not understanding how they affect callbacks. In this post I will try to explain how you can avoid getting fooled by empty observables.

The subscribe operator

The subscribe operator in RxJS provides three callbacks: success, error and complete:

  • Success -> Whenever the Observable emits an item.
  • Error -> Oops something went wrong. Stops the Observable, success or completed will not be called after this.
  • Completed -> Is called after the last success, if no error.

How can empty observables fool you?

The following example illustrates how empty observables can be dangerous:

import { of, EMPTY } from 'rxjs';

const someCriteria = true;

const doSomething = (shouldDoIt) => {
  return shouldDoIt ? of('something has been done') : EMPTY;
}

doSomething(someCriteria)
  .subscribe(
    success => console.log('success: ', success),
    error => console.log('error')
  );

Output: success: something has been done

The output is as expected. But if someCriteria is set to false, there will be no output. Why is this?

Let's review another example:

of(undefined).subscribe(
    success => console.log('success!'),
    error => console.log('error'),
    complete => console.log('complete!')
);

Output: success! complete!

Compare the previous output to the following example using an empty observable:

EMPTY.subscribe(
    success => console.log('success!'),
    error => console.log('error'),
    complete => console.log('complete!')
);

Output: complete!

The success callback is not executed for the empty observable.

The empty operator

The behaviour illustrated in the examples above are actually according to the documentation:
Creates an Observable that emits no items to the Observer and immediately emits a complete notification.

In other words:
The success callback is never called.

Why is this a pitfall?

The pitfall is expecting the success callback to occur, and not handling that it doesn't.

But also, the way you handle it might result in bad code.

Let's say you perform an HTTP request which returns an empty observable if there are no results. You want to set a variable isLoading to false when you get the HTTP response.

Knowing the behaviour of the empty operator, you would have to set the variable in both the error and complete callback. Other scenarioes might require setting it in the success callback aswell.

The result is not pretty:

myService.doSomething().subscribe(
    success => this.isLoading = false,
    error => this.isLoading = false,
    complete => this.isLoading = false
);

Solution: The finalize operator

The finalize operator executes when the observable completes or errors.
It's important to understand the difference between the complete callback and the finalize callback.

The following examples show how the finalize operator can be implemented, and how it ensures that code are executed regardless of the outcome. Notice how the first example only outputs what's given by the finalize callback.

EMPTY
    .pipe(finalize(() => console.log('complete!')))
    .subscribe(
      success => console.log('success!'),
      error => console.log('error!')
    )

Output: complete!

of(undefined)
    .pipe(finalize(() => console.log('complete!')))
    .subscribe(
      success => console.log('success!'),
      error => console.log('error!')
    )

Output: success! complete!

throwError(undefined)
    .pipe(finalize(() => console.log('complete!')))
    .subscribe(
      success => console.log('success!'),
      error => console.log('error!')
    )

Output: error! complete!

Be aware

Empty observables have other dangers aswell.

Consider the commonly used operators - forkJoin and combineLatest:

  • forkJoin -> Waits until the last input observable completes, and then produces a single value (array) and completes.
  • combineLatest -> When all input variables have produced one value, a new is produced each time the input variables changes value. (Meaning it could have infinite values, and may not complete).

But what happens if one of the input variables is an empty observable?

forkJoin(
  of(undefined),
  EMPTY)
    .subscribe(
      success => console.log('success!'),
      error => console.log('error!')
    )

Output: (none)

You can replace forkJoin with combineLatest, the result is the same:
The success callback is not executed.

This might be a dangerous pitfall, and it just shows how important it is to really understand how the different RxJS operators work.

Credit:

Thanks to Anders for raising this issue, and providing examples.