DEV Community

Cover image for Observable State
Alex Skoropad
Alex Skoropad

Posted on

Observable State

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;
}
Enter fullscreen mode Exit fullscreen mode

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 })
            )
          )
        )
      );
  };
}
Enter fullscreen mode Exit fullscreen mode

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$)
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)