Hi there 👋! A few months ago, I shared my approach to managing Observable state on Reddit, maybe some of you are doing the same, maybe not, anyway, I noticed that people liked it, so I decided to write a small article on Dev.to.
What's it about?
The article will cover how to add Observable state, which is sometimes necessary to display a preloader, an error, or the result of a HTTP request.
Having worked in different companies and teams, I've seen various approaches: complex models, pure functions, classes, and more. But in my opinion, if the task sounds simple, its implementation should be simple too!
Let's get started.
Requirements
So, first, let's imagine that we have a server request that returns a list of fruits. It can be complex or simple, but the main things we want to achieve are as follows:
- Display a preloader while the request is in progress.
- Display an error in the template with a retry button if the request fails.
- Display the result if everything is okay.
Coding
Let's start by creating the desired interface for our state.
export interface ObservableState<T, E = Error> {
result: T | null;
error: E | null;
pending: boolean;
}
As you can see, the interface is quite simple, containing only what we need and nothing more!
Now let's talk about creating the state. The most important aspect here is simplicity and reusability, which is why a custom RxJS operator will be a perfect fit!
import type { Observable, OperatorFunction } from 'rxjs';
import { of } from 'rxjs';
import { map, catchError, switchMap, startWith, tap } from 'rxjs/operators';
export interface ObservableState<T, E = Error> {
result: T | null;
error: E | null;
pending: boolean;
}
export function observableState<T, E = Error>(
retry?: Observable<unknown>
): OperatorFunction<T, ObservableState<T, E>> {
return (source: Observable<T>) => {
// Default state
let state: ObservableState<T, E> = {
result: null,
error: null,
pending: false,
};
return (retry?.pipe(startWith(null)) ?? of(null))
.pipe(
switchMap(() =>
source.pipe(
// Map result of observable
map((result: T) => ({ result, pending: false })),
// Map error of observable
catchError((error: E) => of({ result: null, error, pending: false })),
// Start from pending state and clear error
startWith({ error: null, pending: true }),
// Merge the current state with new state and return it
map((updatedState: Partial<ObservableState<T, E>>) =>
(state = { ...state, ...updatedState })
)
)
)
);
};
}
So, as you can see, our operator takes a retry
Observable as input. If it is provided, when it emits, we will restart the source observable. Then, we simply map the result or error and merge it with the main state inside the operator.
As a result, we will have the following behavior:
- When the observable starts, we always start with
preloader: true
, while clearing only the error, so that if it's an update action, our current result doesn't disappear. - If an error occurs, we clear the result and preloader, and return only the state with the error.
- When we receive a result, we turn off the preloader.
Now let's take a look at an example of how to use it:
import { Component, VERSION, ChangeDetectionStrategy } from '@angular/core';
import { Observable, of, Subject, switchMap, throwError } from 'rxjs';
import { delay } from 'rxjs/operators';
import { observableState, ObservableState } from './observable-state';
@Component({
selector: 'my-app',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
// Just to imitate an error for the HttpRequest
imitateError: boolean = false;
// To retry the HttpRequest
retry$: Subject<void> = new Subject<void>();
// Imitation of the HttpRequest
httpRequest$: Observable<string[]>;
// Stated version of the httpRequest$
statedHttpRequest$: Observable<ObservableState<string[]>>;
constructor() {
this.httpRequest$ = of(['Apple', 'Orange', 'Kiwi', 'Pineapple']).pipe(
delay(2000),
switchMap((result: string[]) =>
this.imitateError
? throwError(() => new Error('Something terrible has happened! 👻'))
: of(result)
)
);
// Add state to the HttpRequest
this.statedHttpRequest$ = this.httpRequest$.pipe(
observableState(this.retry$)
);
}
}
As you can see, it's quite simple. We just add our observableState
operator along with the Subject
we created to the existing Observable. This allows us to retry the request in case of an error or for data refresh. Now let's take a look at how it looks in the template:
<section *ngIf="statedHttpRequest$ | async as state">
<div class="header">
<h1>Stated Data</h1>
<button (click)="retry$.next()">retry</button>
<label>
<input type="checkbox" [(ngModel)]="imitateError" />
Imitate an error
</label>
</div>
<div *ngIf="state.pending">Loading...</div>
<ul *ngIf="state.result">
<li *ngFor="let fruit of state.result">{{ fruit }}</li>
</ul>
<div class="error" *ngIf="state.error">{{ state.error }}</div>
</section>
Again, it's simple and straightforward. We have a single state, and based on it, we display different information to the user.
Can we make it better?
Of course! Because there's no limit to perfection! 😃
Often, such states are only displayed in the template, so why do we need code in the TypeScript file, right? Right! Let's create a pipe!
import { Pipe, PipeTransform } from '@angular/core';
import { Observable } from 'rxjs';
import { observableState, ObservableState } from './observable-state';
@Pipe({
name: 'observableState'
})
export class ObservableStatePipe implements PipeTransform {
transform<T, E = Error>(obs: Observable<T>, retry?: Observable<unknown>): Observable<ObservableState<T, E>> {
return obs.pipe(observableState(retry));
}
}
Now we can add the state directly in the template!
<!-- Just add it like this -->
<section *ngIf="httpRequest$ | observableState: retry$ | async as state">
<div class="header">
<h1>Stated Data via Pipe</h1>
<button (click)="retry$.next()">retry</button>
<label>
<input type="checkbox" [(ngModel)]="imitateError" />
Imitate an error
</label>
</div>
<div *ngIf="state.pending">Loading...</div>
<ul *ngIf="state.result">
<li *ngFor="let fruit of state.result">{{ fruit }}</li>
</ul>
<div class="error" *ngIf="state.error">{{ state.error }}</div>
</section>
Live example
It's time to say goodbye 👋
That's all folks! I hope my approach was helpful to you! You can also play around with it on StackBlitz
Top comments (0)