/ngx-remotedata

💥 Synchronize data fetching and UI state

Primary LanguageTypeScriptBSD 3-Clause "New" or "Revised" LicenseBSD-3-Clause

RemoteData

Slaying a UI Antipattern with Angular.

Build Status npm version npm downloads

Library inspired by Kris Jenkins blog post about How Elm slays a UI antipattern, which mixes pretty well with another article written by Scott Hurff about what he calls the UI Stack.

Table Of Contents

We are making an API request and want to display different things based on the request's status.

export interface SunriseSunset {
  isInProgress: boolean;
  error: string;
  data: {
    sunrise: string;
    sunset: string;
  };
}

Let us see what each property means:

  • isInProgress: It is true while the data is being fetched.
  • error: It is either null (no errors) or any string (there are errors).
  • data: Either null (no data) or the result payload (there is data).

There are a few problems with this approach, the main one being that it is possible to create invalid states such as:

{
  "isInProgress": true,
  "error": "Fatal error",
  "data": {
    "sunrise": "I am good data.",
    "sunset": "I am good data too!"
  }
}

Our html template will have to use complex *ngIf statements to make sure we are displaying the correct information.

Instead of using a complex data structures we use a single data type to express all possible request states:

type RemoteData<T, E> = NotAsked | InProgress<T> | Failure<E, T> | Success<T>;

This approach makes it impossible to create invalid states.

npm install --save ngx-remotedata

// app.module.ts

import { RemoteDataModule } from 'ngx-remotedata';

@NgModule({
  imports: [
    // (...)
    RemoteDataModule
  ]
})
// app.component.ts

import {
  RemoteData,
  inProgress,
  notAsked,
  success,
  failure,
} from 'ngx-remotedata';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
})
export class AppComponent {
  remoteData: RemoteData<string> = notAsked();

  setNotAsked() {
    this.remoteData = notAsked();
  }

  setInProgress() {
    this.remoteData = inProgress('In progress...');
  }

  setSuccess() {
    this.remoteData = success('Success!');
  }

  setFailure() {
    this.remoteData = failure('Wrong!');
  }
}
<!-- app.component.html -->

<ul>
  <li><button (click)="setNotAsked()">Not Asked</button></li>
  <li><button (click)="setInProgress()">InProgress</button></li>
  <li><button (click)="setSuccess()">Success</button></li>
  <li><button (click)="setFailure()">Failure</button></li>
</ul>

<hr />

<h4 *ngIf="remoteData | isNotAsked">Not Asked</h4>
<h4 *ngIf="remoteData | isInProgress">InProgress...</h4>
<h4 *ngIf="remoteData | isSuccess" style="color: green">
  {{ remoteData | successValue }}
</h4>
<h4 *ngIf="remoteData | isFailure" style="color: red">
  {{ remoteData | failureValue }}
</h4>

Source code

  • Ngrx (includes store rehydration from localStorage)

RemoteData 📚

RemoteData<T, E>

RemoteData is used to annotate your request variables. It wraps all possible request states into one single union type. Use the parameters to specify:

  • T: The success value type.

  • E: The error value type (string by default).

  • Type guard function: isRemoteData = <T, E>(value: unknown): value is RemoteData<T, E>.

NotAsked 📚

  • Constructor function: notAsked<T, E>(): RemoteData<T, E>.
  • Type guard function: isNotAsked<T, E>(value: unknown): value is NotAsked.

When a RemoteData is a NotAsked instance, it means that the request hasn't been made yet.

type User = { email: string };
const myRemoteData: RemoteData<User> = notAsked();

// (...)

if (isNotAsked(myRemoteData)) {
  // Here myRemoteData is narrowed to NotAsked
}

InProgress 📚

  • Constructor function: inProgress<T, E>(value?: T): RemoteData<T, E>.
  • Type guard function: isInProgress<T, E>(value: unknown): value is InProgress<T>.

When a RemoteData is an InProgress instance, it means that the request has been made, but it hasn't returned any data yet.

The InProgress instance can contain a value of the same T type as Success. Useful when you want to use the last Success value while the new data is being fetched.

type User = { email: string };
const myRemoteData: RemoteData<User> = inProgress({ email: 'john@doe.com' });

// (...)

if (isInProgress(myRemoteData)) {
  // Here myRemoteData is narrowed to InProgress
  console.log(`I have some data: ${myRemoteData.value.email}`);
}

Success 📚

  • Constructor function: success<T, E>(value: T): RemoteData<T, E>.
  • Type guard function: isSuccess<T, E>(value: unknown): value is Success<T>.

When a RemoteData is a Success instance, it means that the request has completed successfully and the new data (of type T) is available.

type User = { email: string };
const myRemoteData: RemoteData<User> = success({ email: 'john@doe.com' });

// (...)

if (isSuccess(myRemoteData)) {
  // Here myRemoteData is narrowed to Success
  console.log(`I have some data: ${myRemoteData.value.email}`);
}

Failure 📚

  • Constructor function: failure<T, E>(err: E, val?: T): RemoteData<T, E>.
  • Type guard function: isFailure<T, E>(value: unknown): value is Failure<E, T>.

When a RemoteData is a Failure instance, it means that the request has failed. You can get the error information (of type E) from the payload.

The Failure instance can contain a value of the same T type as Success. Useful when you want to use the last Success value while displaying the failure message.

type User = { email: string };
const myRemoteData: RemoteData<User> = failure('Something went wrong.', {
  email: 'john@doe.com',
});

// (...)

if (isFailure(myRemoteData)) {
  // Here myRemoteData is narrowed to Failure
  console.log(`This is the failure: ${myRemoteData.error}`);
  console.log(`I have some data: ${myRemoteData.value.email}`);
}

The default type for errors is string, but you can also provide other types like Error:

type User = { email: string };
const myRemoteData: RemoteData<User, Error> = failure(
  new Error('Something went wrong.')
);

Unwrapping RemoteData values

getOrElse 📚

getOrElse<T, E>(rd: RemoteData<T, E>, defaultValue: T): T;

getOrElse unwraps and returns the value of Success instances or the defaultValue when it's any other RemoteData variant.

// Example
let myRemoteData = success('ok!');
console.log(getOrElse(myRemoteData, 'The default value')); // ok!

myRemoteData = failure('There has been an error');
console.log(getOrElse(myRemoteData, 'The default value')); // The default value

fold 📚

fold<T, E>(
  onNotAsked: () => T,
  onInProgress: (value: T | undefined) => T,
  onFailure: (error: E, value: T | undefined) => T,
  onSuccess: (value: T) => T,
  rd: RemoteData<T, E>
): T;

With fold you unwrap the RemoteData value by providing a function for each of the type variants.

// Example
const rd = success('this is fine!');
const result = fold(
  () => 'not asked',
  (val) => 'in progress: ' + val,
  (error, value) => `failure: ${error} ${value}`,
  (value) => 'success: ' + value,
  rd
);
console.log(result); // success: this is fine!

Transforming RemoteData values

map 📚

map<A, B, E>(
  fn: (a: A) => B,
  rd: RemoteData<A, E>
): RemoteData<B, E>;

With map you provide a transformation function that is applied to a RemoteData only when it's a Success instance.

// Example
const scream = (s: string) => s.toUpperCase();
const hello = success('hello!');
const helloScreaming = map(scream, hello);
console.log(helloScreaming); // success('HELLO!')

mapFailure 📚

mapFailure<A, E, F>(
  fn: (e: E) => F,
  rd: RemoteData<A, E>
): RemoteData<A, F>;

With mapFailure you provide a transformation function that is applied to a RemoteData only when it's a Failure instance.

// Example
const scream = (s: string) => s.toUpperCase();
const error = failure('wrong!');
const wrongScreaming = mapFailure(scream, error);
console.log(wrongScreaming); // failure('WRONG!')

chain 📚

chain<A, B, E>(
  fn: (a: A) => RemoteData<B, E>,
  rd: RemoteData<A, E>
): RemoteData<B, E>;

With chain you can provide a transormation function that can change the returned RemoteData variant.

// Example
const checkAge = (n: number) =>
  n >= 0 ? success(n) : failure(`${n} is an invalid age`);
let ageResult = chain(checkAge, success(25));
expect(ageResult).toEqual(success(25));
ageResult = chain(checkAge, success(-3));
expect(ageResult).toEqual(failure('-3  is an invalid age'));

RxJs operators

filterSuccess 📚

Specialized version of the rxjs filter operator for RemoteData values. Emits only when source Observable is a Success, also narrows the emitted value to Success.

// Example
const myRemoteData = success(3);
of(myRemoteData)
  .pipe(
    filterSuccess(),
    map((s) => s.value * 2)
  )
  .subscribe((n) => {
    console.log(n); // 6
  });

filterFailure 📚

Specialized version of the rxjs filter operator for RemoteData values. Emits only when source Observable is a Failure, also narrows the emitted value to Failure.

// Example
const myRemoteData = failure('wrong!');
of(myRemoteData)
  .pipe(
    filterFailure(),
    map((f) => 'Error: ' + f.error)
  )
  .subscribe((err) => {
    console.log(err); // 'Error: wrong!'
  });

isNotAsked 📚

transform<T, E>(rd: RemoteData<T, E>): boolean;

Returns true when RemoteData is a NotAsked instance.

anyIsNotAsked 📚

transform<T, E>(
  rds$: Observable<RemoteData<T, E>>[]
): boolean;

Returns true when any RemoteData<T, E>[] items is a NotAsked instance.

isInProgress 📚

transform<T, E>(rd: RemoteData<T, E>): boolean;

Returns true when RemoteData is an InProgress instance.

anyIsInProgress 📚

transform<T, E>(
  rds$: Observable<RemoteData<T, E>>[]
): boolean;

Returns true when any RemoteData<T, E>[] item is an InProgress instance.

isFailure 📚

transform<T, E>(rd: RemoteData<T, E>): boolean;

Returns true when RemoteData is a Failure instance.

isSuccess 📚

transform<T, E>(rd: RemoteData<T, E>): boolean;

Returns true when RemoteData is a Success instance.

hasValue 📚

transform<T, E>(rd: RemoteData<T, E>): boolean;

Returns true when RemoteData is a Success instance or is an InProgress or Failure instance with a value that is not null or undefined.

successValue 📚

transform<T, E>(
  rd: RemoteData<T, E>,
  defaultValue?: T
): T | undefined;

Returns the Success payload (of type T) when the RemoteData is a Success instance, otherwise it returns the defaultValue when provided or undefined when not.

inProgressValue 📚

transform<T, E>(
  rd: RemoteData<T, E>,
  defaultValue?: T | undefined
): T | undefined;

Returns the InProgress payload (of type T) when RemoteData is an InProgress instance, otherwise it returns the provided defaultValue or undefined when not.

remoteDataValue 📚

transform<T, E>(rd: RemoteData<T, E>): T | E | undefined;

Returns the InProgress, Failure or Success payload (of type T) when RemoteData is an InProgress, Failure or Success instance. Returns undefined otherwise.

failureError 📚

transform<T, E>(rd: RemoteData<T, E>): E | undefined

Returns the Failure error payload (of type E) when RemoteData is a Failure instance or undefined otherwise.

failureValue 📚

transform<T, E>(rd: RemoteData<T, E>): T | undefined

Returns the Failure payload (of type T) when RemoteData is a Failure instance or undefined otherwise.