Building a Flexible and Reusable Data Fetcher with RxJS in TypeScript

Bharath Ravi
3 min readJan 2, 2025

--

(This is a dummy story, this will be removed)
When working with modern front-end applications, fetching and managing data efficiently is crucial. RxJS, with its powerful reactive programming paradigm, provides an excellent foundation for creating flexible and reusable data-fetching utilities. In this article, we’ll dive into implementing a DataFetcher class and explore its practical usage in real-world scenarios.

Why Use a Fetcher?

A fetcher helps you:

  1. Encapsulate Fetch Logic: Keep fetching logic modular and reusable.
  2. Simplify State Management: Decouple data fetching from state handling.
  3. Enable Caching: Avoid redundant API calls and optimize performance.
  4. Streamline Error Handling: Centralize error handling for cleaner code.

The DataFetcher Implementation

The DataFetcher class is a base utility for creating data-fetching logic. It uses RxJS operators like tap and shareReplay to manage data flow efficiently.

import { Observable, defer } from "rxjs";
import { shareReplay, switchMap, tap } from "rxjs/operators";
/**
* Base class to fetch data.
*/
export abstract class DataFetcher<T> {
private _fetchInstance$!: Observable<T>;
abstract fetch(): Observable<T>; abstract afterFetch(data: T): void; getData(): Observable<T> {
if (this._fetchInstance$) {
return this._fetchInstance$;
}
this._fetchInstance$ = this.fetch().pipe(
tap(data => {
this.afterFetch(data);
}),
shareReplay(1)
);
return this._fetchInstance$;
}
}

Key Features

  • Lazy Initialization: The getData method initializes the fetcher only when required.
  • Caching: Using shareReplay(1), the last emitted value is cached and shared with new subscribers.
  • Post-fetch Hook: The afterFetch method allows you to execute custom logic after data is fetched.

Extending the Fetcher: DefaultFetcher

The DefaultFetcher class provides a default implementation for most use cases.

export class DefaultFetcher<T> extends DataFetcher<T> {
constructor(
private latest: () => Observable<T>,
private _afterFetch: (data: T) => void
) {
super();
}
  fetch() {
return this.latest();
}
afterFetch(data: T) {
this._afterFetch(data);
}
}

Factory Function: createFetcher

To simplify instantiation, we introduce a factory function:

export function createFetcher<T>(
fetch: () => Observable<T>,
afterFetch: (data: T) => void
): DataFetcher<T> {
return new DefaultFetcher<T>(fetch, afterFetch);
}

Combining Fetchers with Selectors

The useWithFetcher function ensures data is fetched before a selector emits values. This is especially useful for integrating with state management systems.

export function useWithFetcher<T>(
fetcher: DataFetcher<unknown>,
selector$: Observable<T>
): Observable<T> {
return defer(() => fetcher.getData()).pipe(switchMap(() => selector$));
}

Example Usage

Let’s implement a fetcher for retrieving party information values.

Step 1: Define the Fetcher

The fetcher encapsulates the logic to fetch data and update a BehaviorSubject for state management.

import { BehaviorSubject } from "rxjs";
/**
* Fetcher to get party information values
*/
private partyInformationSectionValuesFetcher = createFetcher(
this.fetchPartyInformationSectionValues.bind(this),
(data: PartyInformationSectionInfo[]) =>
this.partyInformationSectionValuesSub$.next(data)
);
private partyInformationSectionValuesSub$ = new BehaviorSubject<PartyInformationSectionInfo[]>([]);

Step 2: Expose an Observable

We use useWithFetcher to ensure the data is loaded before exposing the selector.

/**
* Emits party information sections values
*/
public partyInfoValues$ = useWithFetcher(
this.partyInformationSectionValuesFetcher,
this.partyInformationSectionValuesSub$ // Selector
);

Step 3: Fetch Data

The fetchPartyInformationSectionValues method fetches fresh data from an API.

private fetchPartyInformationSectionValues(): Observable<PartyInformationSectionInfo[]> {
return this.httpClient.get<PartyInformationSectionInfo[]>('/api/party-info');
}

Step 4: Use the Observable

Subscribe to partyInfoValues$ wherever the data is required, ensuring the fetcher runs only when needed.

this.partyInfoValues$.subscribe(data => {
console.log('Party Information:', data);
});

Advanced Use Cases

Error Handling

Add error handling to the fetcher to ensure resilience.

import { catchError, EMPTY } from "rxjs";
this._fetchInstance$ = this.fetch().pipe(
tap(data => this.afterFetch(data)),
shareReplay(1),
catchError(error => {
console.error("Error fetching data:", error);
return EMPTY;
})
);

Resetting Cache

Include a reset method in DataFetcher to clear cached data.

reset() {
this._fetchInstance$ = undefined!;
}

Conclusion

The DataFetcher utility provides a clean and reusable approach to managing data fetching in Angular applications. By combining it with useWithFetcher and observables, you can ensure efficient, reactive, and modular state management. This approach not only reduces boilerplate but also enhances code readability and maintainability.

Leverage this implementation in your projects to simplify data handling and unlock the power of RxJS!

--

--

Bharath Ravi
Bharath Ravi

No responses yet