share
and ShareReplay
are two RxJs operators that we always struggle to use correctly. We know that we can reach for them when we want to multicast a costly observable or cache a value that will be used at multiple places. But what are the key differences between both, and what is the refCount
flag and how can we leverage its behavior?
In this article, I will try to explain them for you so that you will not need to ask this question again.
Example 1
I posted the following question on Twitter which unfortunately received zero responses. This really highlights the lack of understanding regarding share
and shareReplay
.
The exercice looks like this:
@Component({
selector: 'app-count',
standalone: true,
imports: [NgIf, AsyncPipe],
template: `
<ng-container *ngIf="flag"> {{ count1$ | async }} </ng-container>
<ng-container *ngIf="!flag"> {{ count2$ | async }} </ng-container>
`,
})
export class CountComponent implements OnInit {
flag = true;
readonly count$ = interval(1000).pipe(
take(7),
shareReplay({ bufferSize: 1, refCount: false }) // 👈 line: 15
);
readonly count1$ = this.count$.pipe(
take(3),
map((c) => `count1: ${c}`)
);
readonly count2$ = this.count$.pipe(
take(3),
map((c) => `count2: ${c}`)
);
ngOnInit(): void {
setTimeout(() => {
this.flag = false;
}, 5500);
}
}
Note: This code snippet is written using Angular, but the behavior of both operator is the same outside the framework.
When the flag
is true, we subscribe to count1$
and after 3 emissions, the observable completes. Then after 5500ms, the flag
is set to false and we subscribe to count2$
which also completes after 3 emissions. Both observables are chained with count$
.
The goal is to predict the result displayed on the screen after 10s, depending on the operator used in line 15.
Share
Let's start with the share
operator.
The share
operator will multicast each value emitted by the source observable, which means we won't re-execute the source observable for each new subscription.
Moreover, when the count of subscribers drops to 0, the source observable will be unsubscribed.
Inside this operator, we use a Subject
as a connector between the source observable and the subscribers. This means that every late subscriber will NOT have access to the previously emitted data.
You should use this operator if you know that you will not use previously emitted data and are only interested in upcoming ones.
Solution
If we go back to our exemple:
-
count$
will be triggered whencount1$
subscribes to it. - After
count$
emits 3 values,count1$
completes due to thetake(3)
operator. Consequentlycount$
will also complete since the number of subscribers drops to 0 and the inner Subject will reset. - After 5500ms
count2$
starts. It will subscribe tocount$
andcount$
will start emitting from the beginning. - Since we have a take(3) on the observable, the final answer is 3.
ShareReplay with refCount: true
Both share
and shareReplay
operators behave almost the same: ShareReplay
use share
under the hood. The crucial difference lies in the connector: shareReplay uses a ReplaySubject
instead of a Subject. This distinction becomes significant when dealing with late subscribers, as they will have access to previously emitted data.
Another difference is the ability to toggle the refCount
flag. When refCount=true
, it allows unsuscribing from the source observable when the subscriber count drops to 0. The share
operator's refCount
flag is defaulted to true.
In this scenario with refCount
set to true, the source observable will get unsubscribed when the subscriber count drops to 0.
Solution
If we go back to our exemple:
-
count$
will be triggered whencount1$
subscribes to it. - After
count$
emits 3 times,count1$
completes due to thetake(3)
operator. Consequentlycount$
will also complete since the number of subscribers drops to 0 and therefCount
flag is set to true. - After 5500ms
count2$
starts, it will subscribe tocount$
again andcount$
will start emitting from the beginning. - Since we have a
take(3)
on the observable, the final answer is 3. In this example, bothshare
andshareReplay
bahave exactly the same. However, we will see more examples below to understand the differences between them.
ShareReplay with refCount: false
As explained above, setting the refCount
flag to false will keep the source observable alive even if the subscriber count drops to 0.
This is dangerous because if the source observable never completes, this can create memory leaks.
However in some cases, you may not want to re-execute the source observable if a new subscriber subscribes, such as in the case of an HTTP request.
Solution
If we go back to our exemple:
-
count$
will be triggered whencount1$
subscribes to it. - After
count$
emits 3 times,count1$
completes due to thetake(3)
operator, BUTcount$
will NOT complete and continue to emit a value every 1s. - After 5500ms
count2$
starts, it will subscribe tocount$
and receive the last emitted value which is 4. - Since we have a
take(3)
on the observable, the final answer is 6.
Note: Since all observables completes, we don't have any memory leaks issues
Example 2
Let's take another example to better understand the difference between share
and shareReplay
. This distinction becomes more evident when we apply the operators to an observable that never completes, such as a BehaviorSubject
.
@Component({
selector: 'app-root',
standalone: true,
imports: [NgIf, AsyncPipe],
template: `
<ng-container *ngIf="!flagFinalize">
<ng-container> {{ count1$ | async }} </ng-container>
<ng-container *ngIf="flag"> {{ count2$ | async }} </ng-container>
</ng-container>
<button (click)="flagFinalize = !flagFinalize">FINALIZE</button>
<button (click)="subject.next(subject.value + 1)">INCREMENT</button>
`,
})
export class AppComponent implements OnInit {
flag = false;
flagFinalize = false;
subject = new BehaviorSubject(0);
readonly count$ = this.subject.pipe(
tap({
next: (t) => console.log('I get next value of count', t),
complete: () => console.log('complete count'),
finalize: () => console.log('finalize count'),
}),
share() // 👈
);
readonly count1$ = this.count$.pipe(
tap({
next: (t) => console.log('I get next value of count1', t),
complete: () => console.log('complete count1'),
finalize: () => console.log('finalize count1'),
}),
map((c) => `count1: ${c}`)
);
readonly count2$ = this.count$.pipe(
tap({
next: (t) => console.log('I get next value count2', t),
complete: () => console.log('complete count2'),
finalize: () => console.log('finalize count2'),
}),
map((c) => `count2: ${c}`)
);
ngOnInit(): void {
setTimeout(() => {
this.flag = true;
}, 1000);
}
}
This time we are using a BehaviorSubject
and we have an INCREMENT button to emit a value to the Subject
.
Additionally, we have added a tap
operator to log the next
, complete
and finalize
events.
The FINALIZE button allows us to unsubscribe from the count1$
and count2$
observables.
Scenario
When the app is loaded, count1$
subscribes to count$
, and after 1s count2$
also subscribes to count1$
.
Next, we click once on the INCREMENT button, and finally we click on the FINALIZE button.
Before reading the article further, I invite you to try to guess what the behavior of this scenario will be with each operator. Once you are done, compare your ideas with the solution.
Share
- When
count1$
subscribes tocount$
, thecount$
observable will start emitting values andcount1$
will receive the initial value 0. - In this scenario,
count1$
doesn't complete so the number of subscribers will not drop to 0, andcount$
will not complete either. - After 1s,
count2$
starts subscribing tocount$
, but since we are using the share operator, the inner observable is aSubject
which will not replay the previous emitted value. Thereforecount2$
will not receive any value. - We now click on the INCREMENT button, and both
count1$
andcount2$
will be notified with the value 1. - Finally we click on the FINALIZE button, and both
count1$
andcount2$
will get unsubscribed (due to theasyncPipe
) and finalized. Since theshare
operator unsubscribes the source when all subscribers drop to 0,count$
will finalize as well.
ShareReplay with refCount: true
We replace the share
operator with shareReplay({bufferSize: 1, refCount: true})
- same behavior as previously
- same thing
- After 1s,
count2$
starts subscribing tocount$
. However this time,count2$
gets the previous emitted value (0 in our case), becauseshareReplay
uses aReplaySubject
as the connector. This is the significant difference between both operators
4 and 5 are identical since the refCount
flag is set to true.
ShareReplay with refCount: false
1 to 4 is identical to the previous scenario, and all differences are seen in point 5 when we unsubscribe from the subscribers.
- When we click on the FINALIZE button,
count1$
andcount2$
will be unsubscribed correctly due to theasyncPipe
. Howevercount$
will not finalize becauseshareReplay
will not unsubscribe the inner source andcount$
will continue to exist indefinitely, which might cause a memory leak.
Important note: If you have noticed, I said that this might cause a memory leak. If you lose the reference to your running observable, such as when destroying a component, a new count$
observable will be instantiated next time you initialize the particular component. This can lead to memory leaks.
In the above example, switching back the flag, count1$
and count2$
will resubscribe to the existing observable. ShareReplay
with refCount
set to false is useful when you don't want to re-execute a costly observable like an HTTP request. You can set your shared observable in a global service and inject it anywhere in your application. The instance will never be unsubscribed, but when new subscribers subscribe to the observable, they will use the existing instance.
Note: We often see the shorthand synthax replaySubject(1)
, which is the shorthand for replaySubject({bufferSize: 1, refCount: false})
. So you need to be careful about using this synthax. You will more often reach for refCount
set to true to avoid memory leak issues.
Example 3
In the last example, we will use a source observable that completes, similar to an HTTP request. To simplify the example, we will use the of
operator.
@Component({
selector: 'app-root',
standalone: true,
imports: [NgIf, AsyncPipe],
template: `
<ng-container> {{ request1$ | async }} </ng-container>
<ng-container *ngIf="flag"> {{ request2$ | async }} </ng-container>
`,
})
export class AppComponent implements OnInit {
flag = false;
readonly http$ = of('trigger http request').pipe(
tap({
next: (t) => console.log('http response', t),
complete: () => console.log('complete http'),
finalize: () => console.log('finalize http'),
}),
share() // 👈
);
readonly request1$ = this.http$.pipe(
tap({
next: (t) => console.log('request1 response', t),
complete: () => console.log('complete request1'),
finalize: () => console.log('finalize request1'),
}),
map((c) => `request1: ${c}`)
);
readonly request2$ = this.http$.pipe(
tap({
next: (t) => console.log('request2 response', t),
complete: () => console.log('complete request2'),
finalize: () => console.log('finalize request2'),
}),
map((c) => `request2: ${c}`)
);
ngOnInit(): void {
setTimeout(() => {
this.flag = true;
}, 1000);
}
}
Scenario
When we load the component, we trigger a first HTTP request using the observable http$
. After 1s, we want to get the result of the same request. To achieve this, we consider using either share
or shareReplay
operator to cache the result.
Same exercice as previously, I encourage you to think first before reading the solution below.
Share
-
request1$
subscribes tohttp$
which triggers an HTTP call. Once the response comes back, the HTTP call completes, causinghttp$
andrequest1$
to complete as well. - After 1s,
request2$
subscribes tohttp$
hoping to get the result of the same HTTP call. However, sinceshare
uses aSubject
under the hood, nothing is cached, andhttp$
is re-executed, resulting in a new HTTP call.
ShareReplay
In this scenario, the refCount
doesn't change the behavior since the source observable (http$
) completes on its own, irrespective of the number of subscribers.
- same as previously
- After 1s,
request2$
subscribes tohttp$
but this time,shareReplay
uses aReplaySubject
as the connector, allowing it to store the last value emitted byhttp$
. Thereforehttp$
doesn't need to be re-executed, andrequest2$
received the cached value without triggering a new HTTP call.
Note: Be careful when using shareReplay
inside a global service behind a http call. Each new subscriber will receive the cached value and the HTTP request will NEVER fire again. As a result, your data will NEVER get refreshed.
Conclusion
In summary, shareReplay
is useful in scenarios where you want to cache and replay the last emitted value of an observable, especially in situations like HTTP requests, to avoid unnecessary re-execution and improve performance. But be careful, this is useful inside a component scope, generally not within the global scope.
You need to think carefully about the refCount
flag when using shareReplay
on observables that don't complete on their own.
share
is useful when you want to multicast a long-living observable and you don't need to access previously emitted data.
As you can see, understanding exactly how this two operators work under the hood can help you improve your application's performance.
I hope you now have a better understanding of the differences between share
and shareReplay
and the importance of the refCount
flag. With this knowledge, you should be able to use them correctly and truly understand what is happening behind the scenes.
You can find me on Twitter or Github.Don't hesitate to reach out to me if you have any questions.
Top comments (2)
Code pieces you come up with in your articles are so sweat. Thanks a lot.
thanks pro