Building a Flexible and Reusable Data Fetcher with RxJS in TypeScript
(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:
- Encapsulate Fetch Logic: Keep fetching logic modular and reusable.
- Simplify State Management: Decouple data fetching from state handling.
- Enable Caching: Avoid redundant API calls and optimize performance.
- 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!