Commit f6549f82 authored by Michael Hladky's avatar Michael Hladky
Browse files

add flattening examples

parent 7180f455
......@@ -16,7 +16,7 @@
## Overview
Welcome to my course! My name is Michael and I will lead you through this course.
Welcome to my course! My name is Michael and I will lead you through this content.
The title of this course is reactive architecture and UX patterns.
As those are pretty broad terms let be elaborate a bit on the scope and target audience.
......
......@@ -245,6 +245,46 @@
}
}
}
},
"flattening-operators": {
"projectType": "library",
"root": "projects/flattening-operators",
"sourceRoot": "projects/flattening-operators/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-ng-packagr:build",
"options": {
"tsConfig": "projects/flattening-operators/tsconfig.lib.json",
"project": "projects/flattening-operators/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "projects/flattening-operators/tsconfig.lib.prod.json"
}
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "projects/flattening-operators/src/test.ts",
"tsConfig": "projects/flattening-operators/tsconfig.spec.json",
"karmaConfig": "projects/flattening-operators/karma.conf.js"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"projects/flattening-operators/tsconfig.lib.json",
"projects/flattening-operators/tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
]
}
}
}
}
},
"defaultProject": "Reactive-Architecture-and-UX-Patterns",
......
......@@ -6,8 +6,8 @@ export const MENU_ITEMS: MenuItem[] = [
link: './forkJoin'
},
{
label: 'combineLatest',
link: './combineLatest'
label: 'switchMap',
link: './switchMap'
},
{
label: 'withLatestFrom',
......
......@@ -11,7 +11,7 @@ export const ROUTES = [
.then(m => m.ForkJoinModule)
},
{
path: 'combineLatest',
path: 'switchMap',
loadChildren: () => import('./exercises/combineLatest/combineLatest.module')
.then(m => m.CombineLatestModule)
},
......
......@@ -20,7 +20,7 @@ of binding `Components` directly to HTTP Requests, we will feed the data store w
**Service**
```Typescript
// combine-latest-blog.service.ts
// mergeMap-blog.service.ts
private readonly state$ = new BehaviorSubject<BlogServiceState>({
posts: [] as Post[],
......@@ -68,7 +68,7 @@ We also initially fetch the posts and comments from the server when the service
**Service**
```Typescript
// combine-latest-blog.service.ts
// mergeMap-blog.service.ts
// ...
constructor(...) {
......
......@@ -4,7 +4,7 @@ Combining the http requests with the `combineLatest` operator:
**Component**
```Typescript
// solution.combineLatest.component.ts
// solution.mergeMap.component.ts
blog$: Observable<BlogPost[]> = combineLatest([
this.blogPostService.posts$,
......
......@@ -33,14 +33,14 @@ blogPosts$ = combineLatest([
```
**Numbers of processes for bootstrapping:**
renders: 5 (-1)
processJoinedList: 15 (-12)
processCommentedList: 9 (-8)
renders: 6 (~)
processJoinedList: 21 (-6)
processCommentedList: 13 (-4)
**Numbers of processes for new data:**
renders: 7 (Δ2 => ~)
processJoinedList: 21 (Δ6 => ~)
processCommentedList: 13 (Δ4 => ~)
renders: 8 (Δ2 => ~)
processJoinedList: 27 (Δ6 => ~)
processCommentedList: 17 (Δ4 => ~)
## Step 2 - sharing results
As `blogPosts$` gets subscribed to multiple times, we should share its processed values by using the `share` operator.
......@@ -59,16 +59,16 @@ blogPosts$ = combineLatest([
```
**Numbers of processes for bootstrapping:**
renders: 5 (-1)
processJoinedList: 5 (-22)
processCommentedList: 9 (-8)
renders: 6 (~)
processJoinedList: 7 (-20)
processCommentedList: 13 (-4)
**Numbers of processes for new data:**
renders: 7 (Δ2 => ~)
processJoinedList: 7 (Δ2 => -4)
processCommentedList: 13 (Δ4 => ~)
renders: 8 (Δ2 => ~)
processJoinedList: 9 (Δ2 => -2)
processCommentedList: 17 (Δ4 => ~)
## Step 3 - stream dependencies
## Step 3 - Dependent stream
The first improvements didn't change the way of processing, still led to a performance boost.
To even further improve the performance of our application, lets take a closer look at the relations of the processed `Observables`.
......@@ -99,14 +99,14 @@ commentedBlogPosts$: Observable<BlogPost[]> = zip(
```
**Numbers of processes for bootstrapping:**
renders: 5 (-1)
processJoinedList: 5 (-22)
processCommentedList: 5 (-12)
renders: 6 (-1)
processJoinedList: 7 (-20)
processCommentedList: 7 (-10)
**Numbers of processes for new data:**
renders: 7 (Δ2 => ~)
processJoinedList: 7 (Δ2 => -4)
processCommentedList: 7 (Δ2 => -2)
renders: 8 (Δ2 => ~)
processJoinedList: 9 (Δ2 => -2)
processCommentedList: 9 (Δ2 => -2)
## Conclusion
......
......@@ -12,7 +12,7 @@ import {ZipBlogService} from "combining-streams/lib/exercises/zip/zip-blog-post.
<label>Name</label>
<input matInput name="post" [(ngModel)]="title"/>
</mat-form-field>
<button mat-raised-button color="primary" (click)="blogPostService.addPost({title: title})">Add Comment
<button mat-raised-button color="primary" (click)="blogPostService.addPost({title: title})">Add Post
</button>
<p><b>renders: {{renders()}}</b></p>
......
import { Component } from '@angular/core';
import { ZipBlogService } from 'combining-streams/lib/exercises/zip/zip-blog-post.service';
import { combineLatest, Observable, zip } from 'rxjs';
import { filter, map, share, tap } from 'rxjs/operators';
import {filter, map, share, skip, tap} from 'rxjs/operators';
import { BlogPost, toBlogPosts } from 'shared';
@Component({
......@@ -12,7 +12,7 @@ import { BlogPost, toBlogPosts } from 'shared';
<label>Name</label>
<input matInput name="post" [(ngModel)]="title"/>
</mat-form-field>
<button mat-raised-button color="primary" (click)="blogPostService.addPost({title: title})">Add Comment
<button mat-raised-button color="primary" (click)="blogPostService.addPost({title: title})">Add Post
</button>
<p><b>renders: {{renders()}}</b></p>
......@@ -56,8 +56,8 @@ export class StartZipComponent {
numProcessCommentedList = 0;
blogPosts$ = combineLatest([
this.blogPostService.posts$,
this.blogPostService.comments$
this.blogPostService.posts$.pipe(skip(1)),
this.blogPostService.comments$.pipe(skip(1))
]).pipe(
map(([list, items]) => toBlogPosts(list, items)),
tap(v => ++this.numProcessJoinedList),
......@@ -72,10 +72,10 @@ export class StartZipComponent {
);
// Only commented blog posts
commentedBlogPosts$: Observable<BlogPost[]> = combineLatest([
commentedBlogPosts$: Observable<BlogPost[]> = zip(
this.blogPosts$,
this.commentedIds$
])
)
.pipe(
map(([mergedList, commentedIds]) => (mergedList.filter(i => commentedIds.find(li => li === i.id)))),
tap(v => ++this.numProcessCommentedList)
......
# Combining streams and apply Behaviour
![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 is 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 don'ts 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 to I use, to teach people 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`, `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 walkthrough
In order to showcase the different capabilities and constraints of the combination operators, we will create a very simple Blog application. `Post` & `Comment` are the actual entities which can be fetched from service endpoints.
The combination operators will be used to combine `Post` & `Comment` to `BlogPost`.
```Typescript
// entity
interface Post {
id: string;
title: string;
content: string;
}
// entity
interface Comment {
id: string;
postId: string;
text: string;
}
// derivation
interface BlogPost {
id: string;
title: string;
comments: Comment[];
commentCount: number;
}
```
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`
- 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
- http-service-v1
- [ ] We learn the difference between `forkJoin` and `combineLatest`
- this knowledge helps us to refactor the service and component.
- combineLatest
- [ ] As we go we start to introduce more features into our UI
- again we run into a problem, this time over-rendering.
- zip
- [ ] With a fresh and open mind, 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
- [ ] As it was quite technical so far we learn about `withLatestFrom` with a more playful example
- by doing so we understand the concept of `primary` and `secondary` streams
- withLatestFrom
import {HttpClient, HttpParams} 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 ExhaustMapBlogService {
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[]))
);
}
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[]))
);
}
}
import {NgModule} from '@angular/core';
import {CommonModule} from "@angular/common";
import {RouterModule} from "@angular/router";
import {ROUTES} from "./exhaustMap.routes";
import {MatButtonModule} from "@angular/material/button";
import {MatListModule} from "@angular/material/list";
import {MatFormFieldModule} from "@angular/material/form-field";
import {FormsModule} from "@angular/forms";
import {MatInputModule} from "@angular/material/input";
import {StartExhaustMapComponent} from "./start.exhaustMap.component";
import {SolutionExhaustMapComponent} from "./solution.exhaustMap.component";
@NgModule({
declarations: [
SolutionExhaustMapComponent,
StartExhaustMapComponent
],
imports: [
CommonModule,
MatButtonModule,
MatListModule,
MatFormFieldModule,
MatInputModule,
FormsModule,
RouterModule.forChild(ROUTES)
]
})
export class ExhaustMapModule {
}
import {StartExhaustMapComponent} from "./start.exhaustMap.component";
import {SolutionExhaustMapComponent} from "./solution.exhaustMap.component";
export const ROUTES = [
{
path: '',
children: [
{
path: '',
component: StartExhaustMapComponent
},
{
path: 'solution',
component: SolutionExhaustMapComponent
}
]
}
];
import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core';
import {ExhaustMapBlogService} from './exhaustMap-blog.service';
import {Observable, Subject} from 'rxjs';
import {Post} from 'shared';
import {exhaustMap, tap} from "rxjs/operators";
@Component({
selector: 'solution-exhaustMap',
template: `
<h1>(Solution) exhaustMap</h1>
<mat-form-field>
<label>Name</label>
<input matInput name="post" [(ngModel)]="post"/>
</mat-form-field>
<button mat-raised-button color="primary" (click)="add()">Add Post</button>
<button mat-raised-button color="primary" (click)="update()">Update Posts</button>
<mat-list>
<mat-list-item *ngFor="let item of blog$ | async">
<span mat-line>{{item.title}}</span>
</mat-list-item>
</mat-list>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
})
export class SolutionExhaustMapComponent {
post = 'My new post';
refreshTrigger$ = new Subject();
blog$: Observable<Post[]> = this.refreshTrigger$.pipe(
exhaustMap(() => {
console.log('posts update fired');
return this.blogPostService.httpGetPosts()
})
);
constructor(public blogPostService: ExhaustMapBlogService) {
}
add() {
this.blogPostService.addPost({title: this.post})
}
update() {
this.refreshTrigger$.next();
}
}
import {Component} from '@angular/core';
import {Observable} from 'rxjs';
import {Post} from 'shared';
import {ExhaustMapBlogService} from "./exhaustMap-blog.service";
import {tap} from "rxjs/operators";
@Component({
selector: 'exhaustMap',
template: `
<h1>exhaustMap</h1>
<mat-form-field>
<label>Name</label>
<input matInput name="post" [(ngModel)]="post"/>
</mat-form-field>
<button mat-raised-button color="primary" (click)="add()">Add Post</button>
<button mat-raised-button color="primary" (click)="update()">Update Posts</button>
<mat-list>
<mat-list-item *ngFor="let item of blog$ | async">
<span mat-line>{{item.title}}</span>
</mat-list-item>
</mat-list>
`
})
export class StartExhaustMapComponent {
post = 'My new post';
blog$: Observable<Post[]>;
constructor(public blogPostService: ExhaustMapBlogService) {
}
add() {
this.blogPostService.addPost({title: this.post})
}
update() {
console.log('posts update fired')
this.blog$ = this.blogPostService.httpGetPosts();
}
}
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