Commit be8c6fff authored by micha's avatar micha
Browse files

add code for forkJoin, http-v1, combineLatest

parent 29393d99
# Combining Stream and Behavior
![Reactive Architecture and UX Patterns - Combining Stream and Behavior](./assets/images/Reactive-architecture-and-ux-patterns_angular_combining-streams-and-behavior_michael-hladky.png)
This set of lessons are all about combination operators.
As this course focuses on real-life use cases,
we will use these operators in the context of state derivation, data fetching and update behavior.
We will start with some snippets of code people normally use.
With our examples we will run into some problems with that simple approaches.
As we go, we learn how to solve them,
introduce more robust patterns and finally know all the does and donts of deriving state with combination operators.
![Reactive Architecture and UX Patterns - Combination Operators](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators-dark_michael-hladky.png)
## Combination Operators - Operator Matrix
To get a better understanding of the operators in that group we create a matrix.
The operator matrix is something I came up with to show the relations of the different
operators to each other and the similarities in behavior.
| creation | creation | operator | operator |
|----------------|----------------|-------------------|-------------|
| combineLatest | forkJoin | combineLatestWith | combineAll |
| zip | | zipWith | zipAll |
| | | withLatestFrom | |
## Combination Operators - Algebraic approach
Another learning too I use, to teach people a more efficient learning of operators is the "Algebraic approach".
I named it that way because it borrows concepts and thinking from one of the broad parts of mathematics, [Algebra](https://en.wikipedia.org/wiki/Algebra).
> Literally translated Algebra means "reunion of broken parts".
Understanding the different operators in terms of their "broken parts" is unequally more efficient and intuitive than learning them one by one based on their name.
The list of broken parts from the above set looks like this:
`combineLatest`, `forkJoin`, `zip`, `withLatestFrom`, `With`, `All`
If we understand every of those "broken parts" we are intuitively able to understand their "reunion", meaning the operators itself.
## Combination Operators - Exercise walk through
Within this set of lessons we will walk through the following exercises:
- forkJoin => 2 http in component => Problem: over-fetching because http in component =>
- Http Service refactor to obs$ in service => forkJoin needs complete => intro combineLatest
- combineLatest =>theory => calculate with independent values => Intro num of Items in comp => over-rendering => 1 update 2 emissions
- zip for dependent state calculation values
- withLatestFrom => left right example =>
- opt-in example -> vanilla -> material-dialog
- We start we a simple setup where we derive data in our component directly over HTTP requests by using `forkJoin`
- with this architecture we realize, we quickly run into the problem of over-fetching
- To solve it we refactor the give HTTP service to get more control over when we fetch the data
- this reviles one of the special behaviours of `frokJoin` and we need to rethink it usage
- We learn the difference of `forkJoin` and `combineLatest`
- this knowledge helps us to refactor the service and component.
- As we go we start to introduce more features into our UI
- again we run into a problem, this time over-rendering.
- To understand the problem we learn about the terms `Normalized` and `Denormalized` data
- by using `zip` for our calculation we are able to solve the problem of over-rendering
- As it was quite technical so far we learn about `withLatestFrom` with a more playful example
- by doing so we understand the concept of `promary` and `secondary` streams
- With a fresh and open mine we think about those concepts in combination with a UX Pattern called `opt-in updates`
- to give a better experience to our users we implement this pattern in our example
......@@ -5,14 +5,14 @@ export const MENU_ITEMS: MenuItem[] = [
label: 'forkJoin',
link: './forkJoin'
},
{
label: 'combineLatest',
link: './combineLatest'
},
{
label: 'http service v1',
link: './http-service-v1'
},
{
label: 'combineLatest',
link: './combineLatest'
},
{
label: 'state normalisation and zip',
link: './zip'
......
import { NgModule } from '@angular/core';
import {NgModule} from '@angular/core';
import {CombiningStreamsContainerComponent} from "./combining-streams.container.component";
import {CommonModule} from "@angular/common";
import {RouterModule} from "@angular/router";
......@@ -10,9 +10,10 @@ import {MatButtonModule} from "@angular/material/button";
CombiningStreamsContainerComponent
],
imports: [
CommonModule,
MatButtonModule,
RouterModule.forChild(ROUTES)
CommonModule,
MatButtonModule,
RouterModule.forChild(ROUTES)
]
})
export class CombiningStreamsModule { }
export class CombiningStreamsModule {
}
import {Injectable} from '@angular/core';
import {BehaviorSubject, combineLatest, Observable, of} from 'rxjs';
import {catchError, map} from 'rxjs/operators';
import {Item, JoinedItem, List, mergeListsAndItems, upsertEntities} from "shared";
import {StartHttpV1Service} from "combining-streams/lib/exercises/http-service-v1/start.http-v1.service";
import {HttpClient} from "@angular/common/http";
interface ListServiceState {
lists: List[];
items: Item[];
}
@Injectable({
providedIn: 'root'
})
export class combineLatestListService {
private readonly baseUrl = 'api';
private readonly itemUrl = [this.baseUrl, 'item'].join('/');
private readonly listUrl = [this.baseUrl, 'list'].join('/');
private readonly state$ = new BehaviorSubject<ListServiceState>({
lists: [] as List[],
items: [] as Item[]
});
lists$ = this.state$.pipe(map(s => s.lists));
items$ = this.state$.pipe(map(s => s.items));
constructor(private http: HttpClient) {
}
refetchLists() {
this.httpGetLists()
.subscribe(lists => {
console.log('upsert:', upsertEntities(this.state$.getValue().lists, lists, 'id'));
this.state$.next({
...this.state$.getValue(),
lists: upsertEntities(this.state$.getValue().lists, lists, 'id')
});
});
}
httpGetLists(): Observable<List[]> {
return this.http.get<List[]>(this.listUrl).pipe(
catchError(() => of([] as List[]))
);
}
httpPostItems(item: Pick<Item, 'iName' | 'lId'>): Observable<Item[]> {
return this.http.post<Item[]>(this.itemUrl, item);
}
addItem(item: Pick<Item, 'iName' | 'lId'>) {
this.httpPostItems(item)
.subscribe((v) => {
console.log(v);
this.refetchItems()
}, console.log);
}
refetchItems() {
this.httpGetItems()
.subscribe(items => {
this.state$.next({
...this.state$.getValue(),
items: upsertEntities(this.state$.getValue().items, items, 'id')
});
});
}
httpGetItems(): Observable<Item[]> {
return this.http.get<Item[]>(this.itemUrl).pipe(
catchError(() => of([] as Item[]))
);
}
}
......@@ -6,6 +6,9 @@ import {MatButtonModule} from "@angular/material/button";
import {SolutionCombineLatestComponent} from "combining-streams/lib/exercises/combineLatest/solution.combineLatest.component";
import {StartCombineLatestComponent} from "combining-streams/lib/exercises/combineLatest/start.combineLatest.component";
import {MatListModule} from "@angular/material/list";
import {MatFormFieldModule} from "@angular/material/form-field";
import {FormsModule} from "@angular/forms";
import {MatInputModule} from "@angular/material/input";
@NgModule({
declarations: [
......@@ -16,6 +19,9 @@ import {MatListModule} from "@angular/material/list";
CommonModule,
MatButtonModule,
MatListModule,
MatFormFieldModule,
MatInputModule,
FormsModule,
RouterModule.forChild(ROUTES)
]
})
......
......@@ -2,43 +2,42 @@ import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/co
import {combineLatest, Observable} from "rxjs";
import {map} from "rxjs/operators";
import {JoinedItem, ListService, mergeListsAndItems} from "shared";
import {combineLatestListService} from "combining-streams/lib/exercises/combineLatest/combineLatest-list.service";
@Component({
selector: 'combineLatest',
template: `<h3>combineLatest</h3>
selector: 'solution-combineLatest',
template: `<h3>(solution) combineLatest</h3>
<button mat-raised-button color="primary" (click)="s.refetchItems()">
Refresh Items
</button>
<button mat-raised-button color="primary" (click)="s.refetchLists()">
Refresh Lists
</button>
<mat-form-field>
<label>Name</label>
<input matInput name="iName" [(ngModel)]="iName"/>
</mat-form-field>
<button (click)="listService.addItem({'iName': iName, 'lId': 1})">AddItem</button>
<div *ngIf="list$ | async as list">
<mat-list>
<mat-list-item *ngFor="let item of list">
{{item.iName}} - {{item.lName}}
</mat-list-item>
</mat-list>
<mat-list>
<mat-list-item *ngFor="let item of list">
{{item.iName}} - {{item.lName}}
</mat-list-item>
</mat-list>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
})
export class SolutionCombineLatestComponent {
list$: Observable<JoinedItem[]> = combineLatest(
this.s.items$,
this.s.lists$
)
.pipe(
map(([items, lists]) => mergeListsAndItems(lists, items))
);
constructor(public s: ListService) {
}
iName: string = '';
list$: Observable<JoinedItem[]> = combineLatest([
this.listService.lists$,
this.listService.items$
])
.pipe(
map(([list, items]) => mergeListsAndItems(list, items))
);
constructor(private listService: combineLatestListService) {
this.listService.refetchLists();
this.listService.refetchItems();
}
}
import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core';
import { NEVER, Observable} from "rxjs";
import {JoinedItem, ListService} from "shared";
import {combineLatest, forkJoin, NEVER, Observable} from "rxjs";
import {JoinedItem, ListService, mergeListsAndItems} from "shared";
import {map} from "rxjs/operators";
import {combineLatestListService} from "combining-streams/lib/exercises/combineLatest/combineLatest-list.service";
@Component({
selector: 'combineLatest',
template: `<h3>combineLatest</h3>
<button mat-raised-button color="primary" (click)="s.refetchItems()">
Refresh Items
</button>
<button mat-raised-button color="primary" (click)="s.refetchLists()">
Refresh Lists
</button>
<mat-form-field>
<label>Name</label>
<input matInput name="iName" [(ngModel)]="iName"/>
</mat-form-field>
<button (click)="listService.addItem({'iName': iName, 'lId': 1})">AddItem</button>
<div *ngIf="list$ | async as list">
{{list | json}}
<mat-list>
<mat-list-item *ngFor="let item of list">
{{item.iName}} - {{item.lName}}
</mat-list-item>
</mat-list>
<mat-list>
<mat-list-item *ngFor="let item of list">
{{item.iName}} - {{item.lName}}
</mat-list-item>
</mat-list>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
`
})
export class StartCombineLatestComponent {
iName: string = '';
list$: Observable<JoinedItem[]> = forkJoin([
this.listService.lists$,
this.listService.items$
])
.pipe(
map(([list, items]) => mergeListsAndItems(list, items))
);
/*
1. Use `combineLatest` to join lists$ and todos$ from `SimpleTodoService`)
2. Use `leftJoin(items, lists, 'lId')`
*/
list$: Observable<JoinedItem[]> = NEVER;
constructor(public s: ListService) {
}
constructor(private listService: combineLatestListService) {
this.listService.refetchLists();
this.listService.refetchItems();
}
}
# forkJoin
1. Use `forkJoin` to get lists and todos (httpGetLists(), httpGetTodos())
2. Use `leftJoin(items, lists, 'lId')`
// this.listService.httpGetLists(),
// this.listService.httpGetItems()
// mergeListsAndItems(lists, items)
import {Injectable} from '@angular/core';
import {Observable, of} from 'rxjs';
import {delay, map} from 'rxjs/operators';
import {catchError} from 'rxjs/operators';
import {Item, List} from "shared";
import {HttpClient} from "@angular/common/http";
interface ListServiceState {
lists: List[];
items: Item[];
loading: boolean;
error: string;
}
@Injectable({
providedIn: 'root'
})
export class ForkJoinListService {
private baseUrl = 'api';
private itemUrl = [this.baseUrl, 'item'].join('/');
private listUrl = [this.baseUrl, 'list'].join('/');
constructor() {
constructor(private http: HttpClient) {
}
httpGetItems = (arg?: any): Observable<{ items: Item[] }> =>
of(getItems(arg)).pipe(
// tslint:disable-next-line:no-bitwise
delay(~~(Math.random() * 5000)),
map(items => ({ items }))
);
httpGetLists = (arg?: any): Observable<{ lists: List[] }> =>
of(getLists(arg)).pipe(
// tslint:disable-next-line:no-bitwise
delay(~~(Math.random() * 5000)),
map(lists => ({ lists }))
httpGetItems(): Observable<Item[]> {
return this.http.get<Item[]>(this.itemUrl).pipe(
catchError(e => of([] as Item[]))
);
}
export function getLists(cfg = { num: 5 }): List[] {
// tslint:disable-next-line:no-bitwise
const randId = (s: string = '') => s + ~~(Math.random() * 100);
return new Array(cfg.num).fill(cfg.num).map(_ => ({
lId: randId('lid'),
lName: randId('lname')
}));
}
}
httpGetLists(): Observable<List[]> {
return this.http.get<List[]>(this.listUrl).pipe(
catchError(e => of([] as List[]))
);
}
export function getItems(cfg = { num: 5 }): Item[] {
// tslint:disable-next-line:no-bitwise
const randId = (s: string = '') => s + ~~(Math.random() * 100);
return new Array(cfg.num).fill(cfg.num).map(_ => ({
iId: randId('iid'),
iName: randId('iname'),
lId: randId('lid')
}));
}
......@@ -23,16 +23,14 @@ import {ForkJoinListService} from "combining-streams/lib/exercises/forkJoin/fork
})
export class SolutionForkJoinComponent {
list$: Observable<JoinedItem[]> = forkJoin(
list$: Observable<JoinedItem[]> = forkJoin([
this.listService.httpGetLists(),
this.listService.httpGetItems()
)
.pipe(
map(([listsResult, itemsResult]) => mergeListsAndItems(listsResult.lists, itemsResult.items))
);
]).pipe(
map(([lists, items]) => mergeListsAndItems(lists, items))
);
constructor(private listService: ForkJoinListService) {
}
}
import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core';
import {interval, Observable} from "rxjs";
import {ForkJoinListService} from "combining-streams/lib/exercises/forkJoin/forkJoin-list.service";
import {Observable} from "rxjs";
import {ForkJoinListService} from "./forkJoin-list.service";
import {JoinedItem} from "shared";
@Component({
selector: 'solution-forkJoin',
......@@ -19,10 +20,13 @@ import {ForkJoinListService} from "combining-streams/lib/exercises/forkJoin/fork
})
export class StartForkJoinComponent {
list$: Observable<any> = interval(1000);
list$: Observable<JoinedItem>;
constructor(private listService: ForkJoinListService) {
// this.listService.httpGetLists(),
// this.listService.httpGetItems()
// mergeListsAndItems(lists, items)
constructor(private listService: ForkJoinListService) {
}
}
......@@ -2,13 +2,15 @@ import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/co
import {forkJoin, Observable} from "rxjs";
import {map} from "rxjs/operators";
import {JoinedItem, mergeListsAndItems} from "shared";
import {ForkJoinListService} from "combining-streams/lib/exercises/forkJoin/forkJoin-list.service";
import {combineLatestListService} from "combining-streams/lib/exercises/combineLatest/combineLatest-list.service";
@Component({
selector: 'solution-custom-http-service-v1',
template: `<h3>(Solution) custom-http-service-v1</h3>
<button (click)="listService.addItem({iName: 'new item', lId: 1})">AddItem</button>
<div *ngIf="list$ | async as list">
<mat-list>
<mat-list-item *ngFor="let item of list">
......@@ -16,23 +18,23 @@ import {ForkJoinListService} from "combining-streams/lib/exercises/forkJoin/fork
</mat-list-item>
</mat-list>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
})
export class SolutionHttpServiceV1Component {
list$: Observable<JoinedItem[]> = forkJoin(
this.listService.httpGetLists(),
this.listService.httpGetItems()
)
list$: Observable<JoinedItem[]> = forkJoin([
this.listService.lists$,
this.listService.items$
])
.pipe(
map(([listsResult, itemsResult]) => mergeListsAndItems(listsResult.lists, itemsResult.items))
map(([list, items]) => mergeListsAndItems(list, items))
);
constructor(private listService: ForkJoinListService) {
constructor(private listService: combineLatestListService) {
this.listService.refetchLists();
this.listService.refetchItems();
}
}
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, of } from 'rxjs';
import {
catchError,
delay,
distinctUntilChanged,
map
} from 'rxjs/operators';
import {Item, List} from "shared";
import {Injectable} from '@angular/core';
import {BehaviorSubject, combineLatest, Observable, of} from 'rxjs';
import {catchError, map} from 'rxjs/operators';
import {Item, JoinedItem, List, mergeListsAndItems, upsertEntities} from "shared";
import {StartHttpV1Service} from "combining-streams/lib/exercises/http-service-v1/start.http-v1.service";
import {HttpClient} from "@angular/common/http";
interface ListServiceState {
lists: List[];
items: Item[];
loading: boolean;
error: string;
}
@Injectable({
providedIn: 'root'
})
export class SolutionHttpV1Service {
private readonly baseUrl = 'api';
private readonly itemUrl = [this.baseUrl, 'item'].join('/');
private readonly listUrl = [this.baseUrl, 'list'].join('/');
private readonly state$ = new BehaviorSubject<ListServiceState>({
lists: [] as List[],
items: [] as Item[]
});
lists$ = this.state$.pipe(