Commit 206bc4b0 authored by micha's avatar micha
Browse files

progress

parent 9a3f9ce1
......@@ -36,7 +36,7 @@ I named it that way because it borrows concepts and thinking from one of the bro
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`
`combineLatest`, `zip`, `withLatestFrom`, `With`, `All`
If we understand every of those "broken parts" we are intuitively able to understand their "reunion", meaning the operators itself.
......@@ -72,8 +72,7 @@ Within this set of lessons we will walk through the following exercises:
- [ ] `forkJoin` -> `http-service-v1`
- We start with a very simple list example where we derive data in our component directly over HTTP requests by using `forkJoin`
- List is not "reactive" in terms of "adding/updating" -> build a refetch
- We notice that this architecture results in HTTP over-fetching -> introduce simple state -> forkJoin vs. combineLatest
- We notice that this architecture results in HTTP over-fetching -> introduce simple http caching -> forkJoin vs. combineLatest
- [ ] `combineLatest`
- 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 `forkJoin` and we need to rethink it usage
......
......@@ -5,24 +5,25 @@ export const MENU_ITEMS: MenuItem[] = [
label: 'forkJoin',
link: './forkJoin'
},
{
label: 'http service v1',
link: './http-service-v1'
},
{
label: 'combineLatest',
link: './combineLatest'
},
{
label: 'opt-in updates-v1',
link: './opt-in-updates-v1'
},
{
label: 'state normalisation and zip',
link: './zip'
},
{
label: 'withLatestFrom',
link: './withLatestFrom'
label: 'comparison',
link: './comparison'
},
{
label: 'opt-in updates-v1',
link: './opt-in-updates-v1'
}
label: 'http service v1',
link: './http-service-v1'
},
];
......@@ -26,9 +26,9 @@ export const ROUTES = [
.then(m => m.ZipModule)
},
{
path: 'withLatestFrom',
loadChildren: () => import('./exercises/withLatestFrom/withLatestFrom.module')
.then(m => m.WithLatestFromModule)
path: 'comparison',
loadChildren: () => import('./exercises/comparison/comparison.module')
.then(m => m.ComparisonModule)
},
{
path: 'opt-in-updates-v1',
......
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import {BehaviorSubject, Observable, of} from 'rxjs';
import {catchError, map} from 'rxjs/operators';
import {Comment, Post, upsertEntities} from 'shared';
interface BlogServiceState {
posts: Post[];
comments: Comment[];
}
@Injectable({
providedIn: 'root'
})
export class CombineLatestBlogService {
private readonly baseUrl = 'api';
private readonly commentUrl = [this.baseUrl, 'comment'].join('/');
private readonly postUrl = [this.baseUrl, 'post'].join('/');
private readonly state$ = new BehaviorSubject<BlogServiceState>({
posts: [] as Post[],
comments: [] as Comment[]
});
readonly posts$ = this.state$.pipe(map(s => s.posts));
readonly comments$ = this.state$.pipe(map(s => s.comments));
constructor(private http: HttpClient) {
this.fetchPosts();
this.fetchComments();
}
fetchPosts() {
this.httpGetPosts()
.subscribe(posts => {
this.state$.next({
...this.state$.getValue(),
posts: upsertEntities(this.state$.getValue().posts, posts, 'id')
});
});
}
fetchComments() {
this.httpGetComments()
.subscribe(comments => {
this.state$.next({
...this.state$.getValue(),
comments: upsertEntities(this.state$.getValue().comments, comments, 'id')
});
});
}
addPost(post: Pick<Post, 'title'>) {
this.httpPostPost(post)
.subscribe((newPost) => {
console.log('saved ', newPost , 'to the server');
this.state$.next({
...this.state$.getValue(),
posts: upsertEntities(this.state$.getValue().posts, [newPost], 'id')
})
}, console.log);
}
httpGetPosts(): Observable<Post[]> {
return this.http.get<Post[]>(this.postUrl).pipe(
catchError(() => of([] as Post[]))
);
}
httpPostComment(item: Pick<Comment, 'text' | 'postId'>): Observable<Comment[]> {
return this.http.post<Comment[]>(this.commentUrl, item);
}
httpPostPost(post: Pick<Post, 'title'>): Observable<Post> {
return this.http.post<Post>(this.postUrl, post);
}
httpGetComments(): Observable<Comment[]> {
return this.http.get<Comment[]>(this.commentUrl).pipe(
catchError(() => of([] as Comment[]))
);
}
}
# CombineLatest
# Combining ongoing Observables - Exercise
## Intro
As processing HTTP calls directly in the component we run into "over-fetching" of data.
Over-fetching means we request data from the server more often than we need to.
This is a result of wrong state-management.
We need the result of the HTTP request on a global level in the service.
From there, HTTP result can be cached, share with multiple components and replayed from the cache if needed.
To do so, we implemented a BehaviorSubject in our service as well as
some new methods to update the services state.
**Service**
```Typescript
// combine-latest-blog.service.ts
private readonly state$ = new BehaviorSubject<BlogServiceState>({
posts: [] as Post[],
comments: [] as Comment[]
});
readonly posts$ = this.state$.pipe(map(s => s.posts));
readonly comments$ = this.state$.pipe(map(s => s.comments));
// ...
fetchPosts() {
this.httpGetPosts()
.subscribe(posts => {
this.state$.next({
...this.state$.getValue(),
posts: upsertEntities(this.state$.getValue().posts, posts, 'id')
});
});
}
fetchComments() {
this.httpGetComments()
.subscribe(comments => {
this.state$.next({
...this.state$.getValue(),
comments: upsertEntities(this.state$.getValue().comments, comments, 'id')
});
});
}
addPost(post: Pick<Post, 'title'>) {
this.httpPostPost(post)
.subscribe((v) => {
console.log(v);
// this.fetchPosts();
}, console.log);
}
```
We also initially fetch the posts and comments from the server when the service gets initiated.
**Service**
```Typescript
// combine-latest-blog.service.ts
// ...
constructor(...) {
this.fetchPosts();
this.fetchComments();
}
```
## Exercise
Use the properties `posts$` and `comments$` instead of `httpGetPosts` and `httpGetComments`.
If you click the "add post" button you will notice the calculated blog posts don't emit anymore.
This is because our new streams do not complete anymore, but forkJoin needs all included Observables to complete to emit a value.
We need to replace `forkJoin` with an operator that allows to process the values of running Observables.
In this case `combineLatest` is a perfect match.
Use it and see if the list of BlogPosts renders now.
1. Use `combineLatest` to get lists and todos (over service `SimpleTodoService`)
2. Use `leftJoin(lists, todos, 'lId', 'lId')`
3. return first list
# CombineLatest
# Combining ongoing Observables - Solution
1. Use `combineLatest` to get lists and todos (over service `SimpleTodoService`)
2. Use `leftJoin(lists, todos, 'lId', 'lId')`
3. return first list
## Combining ongoing Observables
Combining the http calls with the `combineLatest` operator:
**Component**
```Typescript
// solution.combineLatest.component.ts
blog$: Observable<BlogPost[]> = combineLatest([
this.blogPostService.posts$,
this.blogPostService.comments$
])
.pipe(
map(([posts, comments]) => toBlogPosts(posts, comments))
);
```
With this setup we can process the list of blog posts whenever the posts, or the comments change.
# `combineLatest` Theory
# `combineLatest` creation function - Theory
## Intro
Often operators are special forms or sub forms of other operators.
If we take a look at the overview of combination patterns we realize that 2 of the look similar, forkJoin and combine.
In the previous example we implemented a self-refetching list of `BlogPost`. We noticed that we were calling problem of over-fetching http calls. To come across this issue
you typically want to introduce some sort of state management.
They both process the values of the included Observables together, but `combineLatest` in comparison to `forkJoin` allows us to
process **every new value** with the latest values of the other included Observables.
![Combination pattern combine and forkJoin](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators-forkJoin-vs-combine_michael-hladky.png)
An example of processing ongoing Observables with `combineLates` looks like that:
```Typescript
import { interval, combineLatest } from "rxjs";
import { map } from "rxjs/operators";
const source1$ = interval(1000);
const source2$ = interval(1000).pipe(map(i => i * 10));
const result$ = combineLatest([source1$, source2$]);
result$
.subscribe((result) => {
console.log(result) // [0,0], [1, 10], [2, 20], [3, 30], ...
})
```
![combineLatest - inner ongoing](assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators-combineLatest-inner-ongoing_michael-hladky.png)
A nice behaviour here is even if one of the included Observables completes we process further values from other ongoing Observables.
![combineLatest - inner complete](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators-combineLatest-inner-complete2_michael-hladky.png)
Only if all included Observables complete the resulting Observable also complete.
![combineLatest - inner complete all](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators-combineLatest-inner-complete_michael-hladky.png)
If an internal Observable errors, the resulting Observable also errors.
![combineLatest - inner error](./assets/images/Reactive-architecture-and-ux-patterns_angular_combination-operators-combineLatest-inner-error_michael-hladky.png)
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
import { combineLatest, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { BlogBasicService, BlogPost, mergeListsAndItems } from 'shared';
import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core';
import {combineLatest, Observable} from 'rxjs';
import {map} from 'rxjs/operators';
import {BlogPost, toBlogPosts} from 'shared';
import {CombineLatestBlogService} from "combining-streams/lib/exercises/combineLatest/combine-latest-blog.service";
@Component({
selector: 'solution-combineLatest',
template: `<h3>(solution) combineLatest</h3>
<mat-form-field>
<label>Name</label>
<input matInput name="text" [(ngModel)]="title"/>
</mat-form-field>
template: `
<h1>(Solution) combineLatest</h1>
<button mat-raised-button color="primary" (click)="addPost()">Add Post</button>
<div *ngIf="blog$ | async as blog">
<div *ngIf="blog$ | async as list">
<mat-list>
<mat-list-item *ngFor="let post of blog">
<span mat-line>{{post.title}}</span>
<span mat-line>Comments: {{post.commentCount}}</span>
<mat-list-item *ngFor="let item of list">
<span mat-line>{{item.title}}</span>
<span mat-line>Comments: {{item.commentCount}}</span>
</mat-list-item>
</mat-list>
</div>
......@@ -27,28 +24,20 @@ import { BlogBasicService, BlogPost, mergeListsAndItems } from 'shared';
encapsulation: ViewEncapsulation.None
})
export class SolutionCombineLatestComponent {
title: string = '';
blog$: Observable<BlogPost[]> = combineLatest([
this.listService.posts$,
this.listService.comments$
this.blogPostService.posts$,
this.blogPostService.comments$
])
.pipe(
map(([posts, comments]) => mergeListsAndItems(posts, comments))
map(([posts, comments]) => toBlogPosts(posts, comments))
);
constructor(public listService: BlogBasicService) {
this.listService.fetchPosts();
this.listService.fetchComments();
constructor(public blogPostService: CombineLatestBlogService) {
}
addPost() {
this.listService.addPost({title: this.title});
this.refetch();
this.blogPostService.addPost({title: 'New post'});
}
refetch() {
this.listService.fetchPosts();
this.listService.fetchComments();
}
}
import { Component } from '@angular/core';
import { combineLatest, forkJoin, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { BlogBasicService, BlogPost, mergeListsAndItems } from 'shared';
import {Component} from '@angular/core';
import {forkJoin, Observable} from 'rxjs';
import {map} from 'rxjs/operators';
import { BlogPost, toBlogPosts} from 'shared';
import {CombineLatestBlogService} from "./combine-latest-blog.service";
@Component({
selector: 'combineLatest',
template: `
<h3>combineLatest</h3>
<mat-form-field>
<label>Title</label>
<input matInput name="title" [(ngModel)]="title"/>
</mat-form-field>
<h1>combineLatest</h1>
<button mat-raised-button color="primary" (click)="addPost()">Add Post</button>
<div *ngIf="blog$ | async as blog">
<div *ngIf="blog$ | async as list">
<mat-list>
<mat-list-item *ngFor="let post of blog">
<span mat-line>{{post.title}}</span>
<span mat-line>Comments: {{post.commentCount}}</span>
<mat-list-item *ngFor="let item of list">
<span mat-line>{{item.title}}</span>
<span mat-line>Comments: {{item.commentCount}}</span>
</mat-list-item>
</mat-list>
</div>
`
})
export class StartCombineLatestComponent {
title: string = '';
blog$: Observable<BlogPost[]> = forkJoin([
this.blogPostService.httpGetPosts(),
this.blogPostService.httpGetComments()
])
.pipe(
map(([posts, comments]) => toBlogPosts(posts, comments))
);
blog$: Observable<BlogPost[]>;
constructor(public blogPostService: CombineLatestBlogService) {
constructor(public listService: BlogBasicService) {
this.refetch();
}
addPost() {
this.listService.addPost({title: this.title});
this.refetch();
}
refetch() {
this.blog$ = this.getBlogList();
this.blogPostService.addPost({title: 'new post'});
}
private getBlogList(): Observable<BlogPost[]> {
return forkJoin([
this.listService.posts$,
this.listService.comments$
])
.pipe(
map(([posts, comments]) => mergeListsAndItems(posts, comments))
);
}
}
import {NgModule} from '@angular/core';
import {CommonModule} from "@angular/common";
import {RouterModule} from "@angular/router";
import {ROUTES} from "./withLatestFrom.routes";
import {ROUTES} from "./comparison.routes";
import {MatButtonModule} from "@angular/material/button";
import {StartWithLatestFromComponent} from "combining-streams/lib/exercises/withLatestFrom/start.withLatestFrom.component";
import {SolutionWithLatestFromComponent} from "combining-streams/lib/exercises/withLatestFrom/solution.withLatestFrom.component";
import {StartComparisonComponent} from "combining-streams/lib/exercises/comparison/start.comparison.component";
import {SolutionComparisonComponent} from "combining-streams/lib/exercises/comparison/solution.comparison.component";
import {MatListModule} from "@angular/material/list";
@NgModule({
declarations: [
StartWithLatestFromComponent,
SolutionWithLatestFromComponent
StartComparisonComponent,
SolutionComparisonComponent
],
imports: [
CommonModule,
......@@ -19,5 +19,5 @@ import {MatListModule} from "@angular/material/list";
RouterModule.forChild(ROUTES)
]
})
export class WithLatestFromModule {
export class ComparisonModule {
}
import {StartWithLatestFromComponent} from "./start.withLatestFrom.component";
import {SolutionWithLatestFromComponent} from "./solution.withLatestFrom.component";
import {StartComparisonComponent} from "./start.comparison.component";
import {SolutionComparisonComponent} from "./solution.comparison.component";
export const ROUTES = [
{
......@@ -7,11 +7,11 @@ export const ROUTES = [
children: [
{
path: '',
component: StartWithLatestFromComponent
component: StartComparisonComponent
},
{
path: 'solution',
component: SolutionWithLatestFromComponent
component: SolutionComparisonComponent
}
]
}
......
Operators:
- `combineLatest`
- `combineLatestWith`
- `zip`
- `zipWith`
- `withLatestFrom`
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment