A Practical Look at Caching with RxJS in Angular
In many applications, certain pieces of data—like navigation menus or configuration options—rarely change. Fetching that data from the server on every route change is inefficient and can hurt the user experience. A better approach is to cache it client-side.
RxJS makes implementing a simple and effective cache straightforward. Two operators, share and shareReplay, are particularly useful for preventing repeated HTTP requests and avoiding redundant calculations.
A Concrete Example
Consider a basic Angular application with two routes: a home page and an about page. The home page displays a list of NBA players. Behind the scenes, the data is processed—for instance, by concatenating a player's first and last name to create a fullName property.
Currently, every time the user navigates away from the home page and then returns, the component is recreated, triggering a new HTTP request and re-running the data transformation. If that transformation were a heavy calculation, this would quickly become wasteful.
The data itself hasn't changed, so why fetch and process it all over again? It's time to introduce some caching.
Leveraging shareReplay for Simple Caching
Adding shareReplay to our data flow is a direct way to improve performance and response time. It ensures the transformation logic (like building the fullName) runs only once, and it keeps a timestamp of when the processing occurred so we can see the benefit in action.
With shareReplay, we can cache the result of the HTTP request and instantly "replay" that data to any new subscribers. The last emission is buffered and re-sent, so subsequent subscriptions don't trigger additional network activity.
Learn more about shareReplay.
To implement this, we add the shareReplay operator at the end of our observable pipeline. This captures the final, processed data from the HTTP request and stores it in a buffer for future subscribers.
@Injectable()
export class NbaService {
api = 'https://www.balldontlie.io/api/v1/';
private teamUrl = this.api + 'players';
public players$ = this.http.get<any[]>(this.teamUrl).pipe(
map((value: any) => {
return value?.data.map((player) => ({
...player,
fullName: `${player.first_name} ${player.last_name}`,
processed: new Date().toISOString(),
}));
}),
shareReplay(1),
);
constructor(private http: HttpClient) {}
}
Once this is in place, the page renders correctly. We can use the Angular Date pipe to neatly format the processing timestamp for better visibility.
<ul *ngIf="players$ | async as players">
<li *ngFor="let player of players">
{{ player.fullName }} {{ player.processed | date: 'medium' }}
</li>
</ul>
Now, when navigating between the home page and another route before returning, the cached data is served instantly. You can confirm in Chrome's Network tab that no new request is made on the return trip.
You can explore the complete example in this StackBlitz project: https://stackblitz.com/edit/angular-ivy-3vql5l?file=src/app/nba.service.ts
That's all it takes to add a cache! However, what happens when we need to invalidate that cache and pull new data?
Refreshing the Cache on Demand
The basic cache works flawlessly for static data. But users often expect a way to manually refresh, especially after an update. Again, RxJS provides a clean pattern for this.
We can use a BehaviorSubject to react to a user-triggered refresh event.
First, we declare a new BehaviorSubject of type void. We also add a public method, like updateData(), that calls next() on the subject to signal a refresh. The HTTP request itself is stored in a private variable, perhaps named apiRequest$.
Our main public observable, players$, now begins by taking the emission from the BehaviorSubject. We use the mergeMap operator to ignore the emitted value and switch to the inner HTTP request observable. Finally, we still append shareReplay to ensure the new response is cached.
Read more about merge.
The updated service code looks like this:
@Injectable()
export class NbaService {
private _playersData$ = new BehaviorSubject<void>(undefined);
api = 'https://www.balldontlie.io/api/v1/';
private teamUrl = this.api + 'players';
apiRequest$ = this.http.get<any[]>(this.teamUrl).pipe(
map((value: any) => {
console.log('getting data from server');
return value?.data.map((player) => ({
...player,
fullName: `${player.first_name} ${
player.last_name
} ${Date.now().toFixed()}`,
}));
})
);
public players$ = this._playersData$.pipe(
mergeMap(() => this.apiRequest$),
shareReplay(1)
);
constructor(private http: HttpClient) {}
updateData() {
this._playersData$.next();
}
}
In the component's template, we can add a button that calls the service's updateData() method. Clicking it triggers the behavior subject, which in turn fires a new HTTP request, updates the cache, and refreshes the displayed data. You can test this functionality without a backend by providing mock data.
The final, interactive code is available here: https://stackblitz.com/edit/angular-ivy-hbf6dc?file=src%2Fapp%2Fpages%2Fhome%2Fhome.component.css
In Summary
With the assistance of RxJS, we can easily introduce a data cache and provide a straightforward mechanism to force it to refresh. It's a simple pattern that can noticeably improve the speed and responsiveness of your application.
For a deeper dive into RxJS and managing data streams, I recommend the talks by @deborahk. She covers these concepts with great clarity.
- Data Composition with RxJS | Deborah Kurata
- Collect, Combine, and Cache RxJS Streams for User-Friendly Results by Deborah Kurata
Photo by Julia Zolotova on Unsplash
