Slaying a UI Antipattern with Angular.
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.
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 istrue
while the data is being fetched.error
: It is eithernull
(no errors) or anystring
(there are errors).data
: Eithernull
(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>
- Ngrx (includes store rehydration from
localStorage
)
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>
.
- 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
}
- 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: '[email protected]' });
// (...)
if (isInProgress(myRemoteData)) {
// Here myRemoteData is narrowed to InProgress
console.log(`I have some data: ${myRemoteData.value.email}`);
}
- 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: '[email protected]' });
// (...)
if (isSuccess(myRemoteData)) {
// Here myRemoteData is narrowed to Success
console.log(`I have some data: ${myRemoteData.value.email}`);
}
- 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: '[email protected]',
});
// (...)
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.')
);
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<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!
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<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<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'));
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
});
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!'
});
transform<T, E>(rd: RemoteData<T, E>): boolean;
Returns true
when RemoteData
is a NotAsked
instance.
transform<T, E>(
rds$: Observable<RemoteData<T, E>>[]
): boolean;
Returns true
when any RemoteData<T, E>[]
items is a NotAsked
instance.
transform<T, E>(rd: RemoteData<T, E>): boolean;
Returns true
when RemoteData
is an InProgress
instance.
transform<T, E>(
rds$: Observable<RemoteData<T, E>>[]
): boolean;
Returns true
when any RemoteData<T, E>[]
item is an InProgress
instance.
transform<T, E>(rd: RemoteData<T, E>): boolean;
Returns true
when RemoteData
is a Failure
instance.
transform<T, E>(rd: RemoteData<T, E>): boolean;
Returns true
when RemoteData
is a Success
instance.
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
.
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.
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.
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.
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.
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.