Why is finalize lifted in RxJS?

Viewed 5237

I am having trouble understanding the finalize operator in RxJS. Let me demonstrate this on an example:

of(null).pipe(
  tap({ complete: () => console.log('tap 1 completes') }),
  finalize(() => console.log('finalize')),
  tap({ complete: () => console.log('tap 2 completes') })
).subscribe({ complete: () => console.log('subscribe completes') });

I would expect the finalize callback to be executed before the second tap. That's not happening, though. Rather the above code produces the following output:

tap 1 completes
tap 2 completes
subscribe completes
finalize

Looking at the implementation I believe the operator is passed (lifted) through the whole observable chain to always be applied at its end. So now I end up with two questions:

  1. What's the rationale behind this design decision? Can you give some explanation on why this is a desireable / advantageous property?
  2. Is there a different operator or other solution to execute code on complete and on error, but in order (i.e. before the second tap in the above example) rather than at the end of the observable chain?
2 Answers

It's important to be aware that finalize() and tap() work very differently. tap() is triggered by next, error and complete notifications while finalize() is only triggerd on chain ubsubscription. In other words finalize() is very similar to using:

const subscription = $source.subscribe();
// This will be always triggered after all `tap()`s
subscription.add(() => console.log('same as finalize()'));

So you can't make finalize() to be invoked before tap(). Also, be aware that finalize() is invoked also when you manually unsubscribe liek the following:

subscription.unsubscribe(); // will always invoke `finalize()` but never `tap()`

One possible solution could be implementing you own finalize() variant that knows the reason why it's being called: https://github.com/martinsik/rxjs-extra/blob/master/doc/finalizeWithReason.md (see source code)

Also note, that https://github.com/ReactiveX/rxjs/pull/5433 will have no affect on your use-case.

That's the whole principle of the finalize operator. To emit only -after- the source observable completes. Which does mean, after all the complete subscriptions have been handled, counting the tap complete. From the docs:

Returns an Observable that mirrors the source Observable, but will call a specified function when the source terminates on complete or error.

Now you could place the finalize in an inner observable, but I suppose you wouldn't like the order then either

of(null).pipe(
  tap({ complete: () => console.log('tap 1 completes') }),
  concatMap((resp) => of(resp).pipe(
    finalize(() => console.log('finalize'))
  )),
  tap({ complete: () => console.log('tap 2 completes') })
).subscribe({ complete: () => console.log('subscribe completes') });

This will make the finalize execute before the first and last tap, this is because of the complete object you pass into tap. If you just pass in a function in the first tap, it will have the right order.

Another way could be the usage of concat:

concat(
  of(null).pipe(
    tap({complete: () => console.log('tap 1 completes' ) }),
    finalize(() => console.log('finalize'))
  ),
  EMPTY.pipe(
    tap({complete: () => console.log('tap 2 completes' ) })
  )
).subscribe({ complete: () => console.log('subscribe completes') });

But this kinda prevents you from accessing what ever the first observable emitted. So, basically, I don't think there is a proper solution to what you are asking :)

Related